Browse Source

Migrate to file-scoped namespaces

Shadowghost 2 years ago
parent
commit
f5f890e685
100 changed files with 20678 additions and 20778 deletions
  1. 19 20
      Jellyfin.Api/Attributes/AcceptsFileAttribute.cs
  2. 11 12
      Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs
  3. 19 20
      Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs
  4. 19 20
      Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs
  5. 7 8
      Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs
  6. 11 12
      Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs
  7. 19 20
      Jellyfin.Api/Attributes/ProducesFileAttribute.cs
  8. 11 12
      Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs
  9. 11 12
      Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs
  10. 11 12
      Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs
  11. 26 27
      Jellyfin.Api/BaseJellyfinApiController.cs
  12. 8 9
      Jellyfin.Api/Constants/AuthenticationSchemes.cs
  13. 32 33
      Jellyfin.Api/Constants/InternalClaimTypes.cs
  14. 60 61
      Jellyfin.Api/Constants/Policies.cs
  15. 16 17
      Jellyfin.Api/Constants/UserRoles.cs
  16. 38 39
      Jellyfin.Api/Controllers/ActivityLogController.cs
  17. 54 55
      Jellyfin.Api/Controllers/ApiKeyController.cs
  18. 404 405
      Jellyfin.Api/Controllers/ArtistsController.cs
  19. 339 340
      Jellyfin.Api/Controllers/AudioController.cs
  20. 42 43
      Jellyfin.Api/Controllers/BrandingController.cs
  21. 207 208
      Jellyfin.Api/Controllers/ChannelsController.cs
  22. 53 54
      Jellyfin.Api/Controllers/ClientLogController.cs
  23. 83 84
      Jellyfin.Api/Controllers/CollectionController.cs
  24. 104 105
      Jellyfin.Api/Controllers/ConfigurationController.cs
  25. 78 79
      Jellyfin.Api/Controllers/DashboardController.cs
  26. 104 105
      Jellyfin.Api/Controllers/DevicesController.cs
  27. 170 171
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  28. 103 104
      Jellyfin.Api/Controllers/DlnaController.cs
  29. 275 276
      Jellyfin.Api/Controllers/DlnaServerController.cs
  30. 1829 1830
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  31. 142 143
      Jellyfin.Api/Controllers/EnvironmentController.cs
  32. 179 180
      Jellyfin.Api/Controllers/FilterController.cs
  33. 161 162
      Jellyfin.Api/Controllers/GenresController.cs
  34. 147 148
      Jellyfin.Api/Controllers/HlsSegmentController.cs
  35. 1860 1861
      Jellyfin.Api/Controllers/ImageController.cs
  36. 322 323
      Jellyfin.Api/Controllers/InstantMixController.cs
  37. 231 232
      Jellyfin.Api/Controllers/ItemLookupController.cs
  38. 62 63
      Jellyfin.Api/Controllers/ItemRefreshController.cs
  39. 332 333
      Jellyfin.Api/Controllers/ItemUpdateController.cs
  40. 793 794
      Jellyfin.Api/Controllers/ItemsController.cs
  41. 780 781
      Jellyfin.Api/Controllers/LibraryController.cs
  42. 254 255
      Jellyfin.Api/Controllers/LibraryStructureController.cs
  43. 1102 1103
      Jellyfin.Api/Controllers/LiveTvController.cs
  44. 57 58
      Jellyfin.Api/Controllers/LocalizationController.cs
  45. 260 261
      Jellyfin.Api/Controllers/MediaInfoController.cs
  46. 234 235
      Jellyfin.Api/Controllers/MoviesController.cs
  47. 151 152
      Jellyfin.Api/Controllers/MusicGenresController.cs
  48. 134 135
      Jellyfin.Api/Controllers/PackageController.cs
  49. 105 106
      Jellyfin.Api/Controllers/PersonsController.cs
  50. 173 174
      Jellyfin.Api/Controllers/PlaylistsController.cs
  51. 317 318
      Jellyfin.Api/Controllers/PlaystateController.cs
  52. 205 206
      Jellyfin.Api/Controllers/PluginsController.cs
  53. 101 102
      Jellyfin.Api/Controllers/QuickConnectController.cs
  54. 138 139
      Jellyfin.Api/Controllers/RemoteImageController.cs
  55. 121 122
      Jellyfin.Api/Controllers/ScheduledTasksController.cs
  56. 201 202
      Jellyfin.Api/Controllers/SearchController.cs
  57. 415 416
      Jellyfin.Api/Controllers/SessionController.cs
  58. 118 119
      Jellyfin.Api/Controllers/StartupController.cs
  59. 117 118
      Jellyfin.Api/Controllers/StudiosController.cs
  60. 443 444
      Jellyfin.Api/Controllers/SubtitleController.cs
  61. 65 66
      Jellyfin.Api/Controllers/SuggestionsController.cs
  62. 378 379
      Jellyfin.Api/Controllers/SyncPlayController.cs
  63. 177 178
      Jellyfin.Api/Controllers/SystemController.cs
  64. 20 21
      Jellyfin.Api/Controllers/TimeSyncController.cs
  65. 278 279
      Jellyfin.Api/Controllers/TrailersController.cs
  66. 316 317
      Jellyfin.Api/Controllers/TvShowsController.cs
  67. 261 262
      Jellyfin.Api/Controllers/UniversalAudioController.cs
  68. 461 462
      Jellyfin.Api/Controllers/UserController.cs
  69. 337 338
      Jellyfin.Api/Controllers/UserLibraryController.cs
  70. 97 98
      Jellyfin.Api/Controllers/UserViewsController.cs
  71. 56 57
      Jellyfin.Api/Controllers/VideoAttachmentsController.cs
  72. 585 586
      Jellyfin.Api/Controllers/VideosController.cs
  73. 165 166
      Jellyfin.Api/Controllers/YearsController.cs
  74. 85 86
      Jellyfin.Api/Extensions/DtoExtensions.cs
  75. 10 11
      Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs
  76. 22 23
      Jellyfin.Api/Formatters/CssOutputFormatter.cs
  77. 12 13
      Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs
  78. 18 19
      Jellyfin.Api/Formatters/XmlOutputFormatter.cs
  79. 136 137
      Jellyfin.Api/Helpers/AudioHelper.cs
  80. 581 582
      Jellyfin.Api/Helpers/DynamicHlsHelper.cs
  81. 85 86
      Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
  82. 153 154
      Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
  83. 96 97
      Jellyfin.Api/Helpers/HlsHelpers.cs
  84. 386 387
      Jellyfin.Api/Helpers/MediaInfoHelper.cs
  85. 130 131
      Jellyfin.Api/Helpers/ProgressiveFileStream.cs
  86. 105 106
      Jellyfin.Api/Helpers/RequestHelpers.cs
  87. 616 617
      Jellyfin.Api/Helpers/StreamingHelpers.cs
  88. 703 704
      Jellyfin.Api/Helpers/TranscodingJobHelper.cs
  89. 57 58
      Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs
  90. 107 108
      Jellyfin.Api/Middleware/ExceptionMiddleware.cs
  91. 31 32
      Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs
  92. 27 28
      Jellyfin.Api/Middleware/LanFilteringMiddleware.cs
  93. 37 38
      Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs
  94. 24 25
      Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs
  95. 47 48
      Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs
  96. 31 32
      Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs
  97. 33 34
      Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs
  98. 51 52
      Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs
  99. 25 26
      Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs
  100. 56 57
      Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs

+ 19 - 20
Jellyfin.Api/Attributes/AcceptsFileAttribute.cs

@@ -2,29 +2,28 @@
 
 using System;
 
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Internal produces image attribute.
+/// </summary>
+[AttributeUsage(AttributeTargets.Method)]
+public class AcceptsFileAttribute : Attribute
 {
+    private readonly string[] _contentTypes;
+
     /// <summary>
-    /// Internal produces image attribute.
+    /// Initializes a new instance of the <see cref="AcceptsFileAttribute"/> class.
     /// </summary>
-    [AttributeUsage(AttributeTargets.Method)]
-    public class AcceptsFileAttribute : Attribute
+    /// <param name="contentTypes">Content types this endpoint produces.</param>
+    public AcceptsFileAttribute(params string[] contentTypes)
     {
-        private readonly string[] _contentTypes;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="AcceptsFileAttribute"/> class.
-        /// </summary>
-        /// <param name="contentTypes">Content types this endpoint produces.</param>
-        public AcceptsFileAttribute(params string[] contentTypes)
-        {
-            _contentTypes = contentTypes;
-        }
-
-        /// <summary>
-        /// Gets the configured content types.
-        /// </summary>
-        /// <returns>the configured content types.</returns>
-        public string[] ContentTypes => _contentTypes;
+        _contentTypes = contentTypes;
     }
+
+    /// <summary>
+    /// Gets the configured content types.
+    /// </summary>
+    /// <returns>the configured content types.</returns>
+    public string[] ContentTypes => _contentTypes;
 }

+ 11 - 12
Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs

@@ -1,18 +1,17 @@
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Produces file attribute of "image/*".
+/// </summary>
+public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute
 {
+    private const string ContentType = "image/*";
+
     /// <summary>
-    /// Produces file attribute of "image/*".
+    /// Initializes a new instance of the <see cref="AcceptsImageFileAttribute"/> class.
     /// </summary>
-    public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute
+    public AcceptsImageFileAttribute()
+        : base(ContentType)
     {
-        private const string ContentType = "image/*";
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="AcceptsImageFileAttribute"/> class.
-        /// </summary>
-        public AcceptsImageFileAttribute()
-            : base(ContentType)
-        {
-        }
     }
 }

+ 19 - 20
Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs

@@ -2,29 +2,28 @@ using System;
 using System.Collections.Generic;
 using Microsoft.AspNetCore.Mvc.Routing;
 
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Identifies an action that supports the HTTP GET method.
+/// </summary>
+public sealed class HttpSubscribeAttribute : HttpMethodAttribute
 {
+    private static readonly IEnumerable<string> _supportedMethods = new[] { "SUBSCRIBE" };
+
     /// <summary>
-    /// Identifies an action that supports the HTTP GET method.
+    /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class.
     /// </summary>
-    public sealed class HttpSubscribeAttribute : HttpMethodAttribute
+    public HttpSubscribeAttribute()
+        : base(_supportedMethods)
     {
-        private static readonly IEnumerable<string> _supportedMethods = new[] { "SUBSCRIBE" };
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class.
-        /// </summary>
-        public HttpSubscribeAttribute()
-            : base(_supportedMethods)
-        {
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class.
-        /// </summary>
-        /// <param name="template">The route template. May not be null.</param>
-        public HttpSubscribeAttribute(string template)
-            : base(_supportedMethods, template)
-            => ArgumentNullException.ThrowIfNull(template);
     }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class.
+    /// </summary>
+    /// <param name="template">The route template. May not be null.</param>
+    public HttpSubscribeAttribute(string template)
+        : base(_supportedMethods, template)
+        => ArgumentNullException.ThrowIfNull(template);
 }

+ 19 - 20
Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs

@@ -2,29 +2,28 @@ using System;
 using System.Collections.Generic;
 using Microsoft.AspNetCore.Mvc.Routing;
 
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Identifies an action that supports the HTTP GET method.
+/// </summary>
+public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute
 {
+    private static readonly IEnumerable<string> _supportedMethods = new[] { "UNSUBSCRIBE" };
+
     /// <summary>
-    /// Identifies an action that supports the HTTP GET method.
+    /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class.
     /// </summary>
-    public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute
+    public HttpUnsubscribeAttribute()
+        : base(_supportedMethods)
     {
-        private static readonly IEnumerable<string> _supportedMethods = new[] { "UNSUBSCRIBE" };
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class.
-        /// </summary>
-        public HttpUnsubscribeAttribute()
-            : base(_supportedMethods)
-        {
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class.
-        /// </summary>
-        /// <param name="template">The route template. May not be null.</param>
-        public HttpUnsubscribeAttribute(string template)
-            : base(_supportedMethods, template)
-            => ArgumentNullException.ThrowIfNull(template);
     }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class.
+    /// </summary>
+    /// <param name="template">The route template. May not be null.</param>
+    public HttpUnsubscribeAttribute(string template)
+        : base(_supportedMethods, template)
+        => ArgumentNullException.ThrowIfNull(template);
 }

+ 7 - 8
Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs

@@ -1,12 +1,11 @@
 using System;
 
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Attribute to mark a parameter as obsolete.
+/// </summary>
+[AttributeUsage(AttributeTargets.Parameter)]
+public sealed class ParameterObsoleteAttribute : Attribute
 {
-    /// <summary>
-    /// Attribute to mark a parameter as obsolete.
-    /// </summary>
-    [AttributeUsage(AttributeTargets.Parameter)]
-    public sealed class ParameterObsoleteAttribute : Attribute
-    {
-    }
 }

+ 11 - 12
Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs

@@ -1,18 +1,17 @@
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Produces file attribute of "image/*".
+/// </summary>
+public sealed class ProducesAudioFileAttribute : ProducesFileAttribute
 {
+    private const string ContentType = "audio/*";
+
     /// <summary>
-    /// Produces file attribute of "image/*".
+    /// Initializes a new instance of the <see cref="ProducesAudioFileAttribute"/> class.
     /// </summary>
-    public sealed class ProducesAudioFileAttribute : ProducesFileAttribute
+    public ProducesAudioFileAttribute()
+        : base(ContentType)
     {
-        private const string ContentType = "audio/*";
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProducesAudioFileAttribute"/> class.
-        /// </summary>
-        public ProducesAudioFileAttribute()
-            : base(ContentType)
-        {
-        }
     }
 }

+ 19 - 20
Jellyfin.Api/Attributes/ProducesFileAttribute.cs

@@ -2,29 +2,28 @@
 
 using System;
 
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Internal produces image attribute.
+/// </summary>
+[AttributeUsage(AttributeTargets.Method)]
+public class ProducesFileAttribute : Attribute
 {
+    private readonly string[] _contentTypes;
+
     /// <summary>
-    /// Internal produces image attribute.
+    /// Initializes a new instance of the <see cref="ProducesFileAttribute"/> class.
     /// </summary>
-    [AttributeUsage(AttributeTargets.Method)]
-    public class ProducesFileAttribute : Attribute
+    /// <param name="contentTypes">Content types this endpoint produces.</param>
+    public ProducesFileAttribute(params string[] contentTypes)
     {
-        private readonly string[] _contentTypes;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProducesFileAttribute"/> class.
-        /// </summary>
-        /// <param name="contentTypes">Content types this endpoint produces.</param>
-        public ProducesFileAttribute(params string[] contentTypes)
-        {
-            _contentTypes = contentTypes;
-        }
-
-        /// <summary>
-        /// Gets the configured content types.
-        /// </summary>
-        /// <returns>the configured content types.</returns>
-        public string[] ContentTypes => _contentTypes;
+        _contentTypes = contentTypes;
     }
+
+    /// <summary>
+    /// Gets the configured content types.
+    /// </summary>
+    /// <returns>the configured content types.</returns>
+    public string[] ContentTypes => _contentTypes;
 }

+ 11 - 12
Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs

@@ -1,18 +1,17 @@
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Produces file attribute of "image/*".
+/// </summary>
+public sealed class ProducesImageFileAttribute : ProducesFileAttribute
 {
+    private const string ContentType = "image/*";
+
     /// <summary>
-    /// Produces file attribute of "image/*".
+    /// Initializes a new instance of the <see cref="ProducesImageFileAttribute"/> class.
     /// </summary>
-    public sealed class ProducesImageFileAttribute : ProducesFileAttribute
+    public ProducesImageFileAttribute()
+        : base(ContentType)
     {
-        private const string ContentType = "image/*";
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProducesImageFileAttribute"/> class.
-        /// </summary>
-        public ProducesImageFileAttribute()
-            : base(ContentType)
-        {
-        }
     }
 }

+ 11 - 12
Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs

@@ -1,18 +1,17 @@
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Produces file attribute of "image/*".
+/// </summary>
+public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute
 {
+    private const string ContentType = "application/x-mpegURL";
+
     /// <summary>
-    /// Produces file attribute of "image/*".
+    /// Initializes a new instance of the <see cref="ProducesPlaylistFileAttribute"/> class.
     /// </summary>
-    public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute
+    public ProducesPlaylistFileAttribute()
+        : base(ContentType)
     {
-        private const string ContentType = "application/x-mpegURL";
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProducesPlaylistFileAttribute"/> class.
-        /// </summary>
-        public ProducesPlaylistFileAttribute()
-            : base(ContentType)
-        {
-        }
     }
 }

+ 11 - 12
Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs

@@ -1,18 +1,17 @@
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Produces file attribute of "video/*".
+/// </summary>
+public sealed class ProducesVideoFileAttribute : ProducesFileAttribute
 {
+    private const string ContentType = "video/*";
+
     /// <summary>
-    /// Produces file attribute of "video/*".
+    /// Initializes a new instance of the <see cref="ProducesVideoFileAttribute"/> class.
     /// </summary>
-    public sealed class ProducesVideoFileAttribute : ProducesFileAttribute
+    public ProducesVideoFileAttribute()
+        : base(ContentType)
     {
-        private const string ContentType = "video/*";
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProducesVideoFileAttribute"/> class.
-        /// </summary>
-        public ProducesVideoFileAttribute()
-            : base(ContentType)
-        {
-        }
     }
 }

+ 26 - 27
Jellyfin.Api/BaseJellyfinApiController.cs

@@ -4,35 +4,34 @@ using Jellyfin.Api.Results;
 using Jellyfin.Extensions.Json;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api
+namespace Jellyfin.Api;
+
+/// <summary>
+/// Base api controller for the API setting a default route.
+/// </summary>
+[ApiController]
+[Route("[controller]")]
+[Produces(
+    MediaTypeNames.Application.Json,
+    JsonDefaults.CamelCaseMediaType,
+    JsonDefaults.PascalCaseMediaType)]
+public class BaseJellyfinApiController : ControllerBase
 {
     /// <summary>
-    /// Base api controller for the API setting a default route.
+    /// Create a new <see cref="OkResult{T}"/>.
     /// </summary>
-    [ApiController]
-    [Route("[controller]")]
-    [Produces(
-        MediaTypeNames.Application.Json,
-        JsonDefaults.CamelCaseMediaType,
-        JsonDefaults.PascalCaseMediaType)]
-    public class BaseJellyfinApiController : ControllerBase
-    {
-        /// <summary>
-        /// Create a new <see cref="OkResult{T}"/>.
-        /// </summary>
-        /// <param name="value">The value to return.</param>
-        /// <typeparam name="T">The type to return.</typeparam>
-        /// <returns>The <see cref="ActionResult{T}"/>.</returns>
-        protected ActionResult<IEnumerable<T>> Ok<T>(IEnumerable<T>? value)
-            => new OkResult<IEnumerable<T>?>(value);
+    /// <param name="value">The value to return.</param>
+    /// <typeparam name="T">The type to return.</typeparam>
+    /// <returns>The <see cref="ActionResult{T}"/>.</returns>
+    protected ActionResult<IEnumerable<T>> Ok<T>(IEnumerable<T>? value)
+        => new OkResult<IEnumerable<T>?>(value);
 
-        /// <summary>
-        /// Create a new <see cref="OkResult{T}"/>.
-        /// </summary>
-        /// <param name="value">The value to return.</param>
-        /// <typeparam name="T">The type to return.</typeparam>
-        /// <returns>The <see cref="ActionResult{T}"/>.</returns>
-        protected ActionResult<T> Ok<T>(T value)
-            => new OkResult<T>(value);
-    }
+    /// <summary>
+    /// Create a new <see cref="OkResult{T}"/>.
+    /// </summary>
+    /// <param name="value">The value to return.</param>
+    /// <typeparam name="T">The type to return.</typeparam>
+    /// <returns>The <see cref="ActionResult{T}"/>.</returns>
+    protected ActionResult<T> Ok<T>(T value)
+        => new OkResult<T>(value);
 }

+ 8 - 9
Jellyfin.Api/Constants/AuthenticationSchemes.cs

@@ -1,13 +1,12 @@
-namespace Jellyfin.Api.Constants
+namespace Jellyfin.Api.Constants;
+
+/// <summary>
+/// Authentication schemes for user authentication in the API.
+/// </summary>
+public static class AuthenticationSchemes
 {
     /// <summary>
-    /// Authentication schemes for user authentication in the API.
+    /// Scheme name for the custom legacy authentication.
     /// </summary>
-    public static class AuthenticationSchemes
-    {
-        /// <summary>
-        /// Scheme name for the custom legacy authentication.
-        /// </summary>
-        public const string CustomAuthentication = "CustomAuthentication";
-    }
+    public const string CustomAuthentication = "CustomAuthentication";
 }

+ 32 - 33
Jellyfin.Api/Constants/InternalClaimTypes.cs

@@ -1,43 +1,42 @@
-namespace Jellyfin.Api.Constants
+namespace Jellyfin.Api.Constants;
+
+/// <summary>
+/// Internal claim types for authorization.
+/// </summary>
+public static class InternalClaimTypes
 {
     /// <summary>
-    /// Internal claim types for authorization.
+    /// User Id.
     /// </summary>
-    public static class InternalClaimTypes
-    {
-        /// <summary>
-        /// User Id.
-        /// </summary>
-        public const string UserId = "Jellyfin-UserId";
+    public const string UserId = "Jellyfin-UserId";
 
-        /// <summary>
-        /// Device Id.
-        /// </summary>
-        public const string DeviceId = "Jellyfin-DeviceId";
+    /// <summary>
+    /// Device Id.
+    /// </summary>
+    public const string DeviceId = "Jellyfin-DeviceId";
 
-        /// <summary>
-        /// Device.
-        /// </summary>
-        public const string Device = "Jellyfin-Device";
+    /// <summary>
+    /// Device.
+    /// </summary>
+    public const string Device = "Jellyfin-Device";
 
-        /// <summary>
-        /// Client.
-        /// </summary>
-        public const string Client = "Jellyfin-Client";
+    /// <summary>
+    /// Client.
+    /// </summary>
+    public const string Client = "Jellyfin-Client";
 
-        /// <summary>
-        /// Version.
-        /// </summary>
-        public const string Version = "Jellyfin-Version";
+    /// <summary>
+    /// Version.
+    /// </summary>
+    public const string Version = "Jellyfin-Version";
 
-        /// <summary>
-        /// Token.
-        /// </summary>
-        public const string Token = "Jellyfin-Token";
+    /// <summary>
+    /// Token.
+    /// </summary>
+    public const string Token = "Jellyfin-Token";
 
-        /// <summary>
-        /// Is Api Key.
-        /// </summary>
-        public const string IsApiKey = "Jellyfin-IsApiKey";
-    }
+    /// <summary>
+    /// Is Api Key.
+    /// </summary>
+    public const string IsApiKey = "Jellyfin-IsApiKey";
 }

+ 60 - 61
Jellyfin.Api/Constants/Policies.cs

@@ -1,78 +1,77 @@
-namespace Jellyfin.Api.Constants
+namespace Jellyfin.Api.Constants;
+
+/// <summary>
+/// Policies for the API authorization.
+/// </summary>
+public static class Policies
 {
     /// <summary>
-    /// Policies for the API authorization.
+    /// Policy name for default authorization.
     /// </summary>
-    public static class Policies
-    {
-        /// <summary>
-        /// Policy name for default authorization.
-        /// </summary>
-        public const string DefaultAuthorization = "DefaultAuthorization";
+    public const string DefaultAuthorization = "DefaultAuthorization";
 
-        /// <summary>
-        /// Policy name for requiring first time setup or elevated privileges.
-        /// </summary>
-        public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated";
+    /// <summary>
+    /// Policy name for requiring first time setup or elevated privileges.
+    /// </summary>
+    public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated";
 
-        /// <summary>
-        /// Policy name for requiring elevated privileges.
-        /// </summary>
-        public const string RequiresElevation = "RequiresElevation";
+    /// <summary>
+    /// Policy name for requiring elevated privileges.
+    /// </summary>
+    public const string RequiresElevation = "RequiresElevation";
 
-        /// <summary>
-        /// Policy name for allowing local access only.
-        /// </summary>
-        public const string LocalAccessOnly = "LocalAccessOnly";
+    /// <summary>
+    /// Policy name for allowing local access only.
+    /// </summary>
+    public const string LocalAccessOnly = "LocalAccessOnly";
 
-        /// <summary>
-        /// Policy name for escaping schedule controls.
-        /// </summary>
-        public const string IgnoreParentalControl = "IgnoreParentalControl";
+    /// <summary>
+    /// Policy name for escaping schedule controls.
+    /// </summary>
+    public const string IgnoreParentalControl = "IgnoreParentalControl";
 
-        /// <summary>
-        /// Policy name for requiring download permission.
-        /// </summary>
-        public const string Download = "Download";
+    /// <summary>
+    /// Policy name for requiring download permission.
+    /// </summary>
+    public const string Download = "Download";
 
-        /// <summary>
-        /// Policy name for requiring first time setup or default permissions.
-        /// </summary>
-        public const string FirstTimeSetupOrDefault = "FirstTimeSetupOrDefault";
+    /// <summary>
+    /// Policy name for requiring first time setup or default permissions.
+    /// </summary>
+    public const string FirstTimeSetupOrDefault = "FirstTimeSetupOrDefault";
 
-        /// <summary>
-        /// Policy name for requiring local access or elevated privileges.
-        /// </summary>
-        public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation";
+    /// <summary>
+    /// Policy name for requiring local access or elevated privileges.
+    /// </summary>
+    public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation";
 
-        /// <summary>
-        /// Policy name for requiring (anonymous) LAN access.
-        /// </summary>
-        public const string AnonymousLanAccessPolicy = "AnonymousLanAccessPolicy";
+    /// <summary>
+    /// Policy name for requiring (anonymous) LAN access.
+    /// </summary>
+    public const string AnonymousLanAccessPolicy = "AnonymousLanAccessPolicy";
 
-        /// <summary>
-        /// Policy name for escaping schedule controls or requiring first time setup.
-        /// </summary>
-        public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl";
+    /// <summary>
+    /// Policy name for escaping schedule controls or requiring first time setup.
+    /// </summary>
+    public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl";
 
-        /// <summary>
-        /// Policy name for accessing SyncPlay.
-        /// </summary>
-        public const string SyncPlayHasAccess = "SyncPlayHasAccess";
+    /// <summary>
+    /// Policy name for accessing SyncPlay.
+    /// </summary>
+    public const string SyncPlayHasAccess = "SyncPlayHasAccess";
 
-        /// <summary>
-        /// Policy name for creating a SyncPlay group.
-        /// </summary>
-        public const string SyncPlayCreateGroup = "SyncPlayCreateGroup";
+    /// <summary>
+    /// Policy name for creating a SyncPlay group.
+    /// </summary>
+    public const string SyncPlayCreateGroup = "SyncPlayCreateGroup";
 
-        /// <summary>
-        /// Policy name for joining a SyncPlay group.
-        /// </summary>
-        public const string SyncPlayJoinGroup = "SyncPlayJoinGroup";
+    /// <summary>
+    /// Policy name for joining a SyncPlay group.
+    /// </summary>
+    public const string SyncPlayJoinGroup = "SyncPlayJoinGroup";
 
-        /// <summary>
-        /// Policy name for accessing a SyncPlay group.
-        /// </summary>
-        public const string SyncPlayIsInGroup = "SyncPlayIsInGroup";
-    }
+    /// <summary>
+    /// Policy name for accessing a SyncPlay group.
+    /// </summary>
+    public const string SyncPlayIsInGroup = "SyncPlayIsInGroup";
 }

+ 16 - 17
Jellyfin.Api/Constants/UserRoles.cs

@@ -1,23 +1,22 @@
-namespace Jellyfin.Api.Constants
+namespace Jellyfin.Api.Constants;
+
+/// <summary>
+/// Constants for user roles used in the authentication and authorization for the API.
+/// </summary>
+public static class UserRoles
 {
     /// <summary>
-    /// Constants for user roles used in the authentication and authorization for the API.
+    /// Guest user.
     /// </summary>
-    public static class UserRoles
-    {
-        /// <summary>
-        /// Guest user.
-        /// </summary>
-        public const string Guest = "Guest";
+    public const string Guest = "Guest";
 
-        /// <summary>
-        /// Regular user with no special privileges.
-        /// </summary>
-        public const string User = "User";
+    /// <summary>
+    /// Regular user with no special privileges.
+    /// </summary>
+    public const string User = "User";
 
-        /// <summary>
-        /// Administrator user with elevated privileges.
-        /// </summary>
-        public const string Administrator = "Administrator";
-    }
+    /// <summary>
+    /// Administrator user with elevated privileges.
+    /// </summary>
+    public const string Administrator = "Administrator";
 }

+ 38 - 39
Jellyfin.Api/Controllers/ActivityLogController.cs

@@ -8,50 +8,49 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Activity log controller.
+/// </summary>
+[Route("System/ActivityLog")]
+[Authorize(Policy = Policies.RequiresElevation)]
+public class ActivityLogController : BaseJellyfinApiController
 {
+    private readonly IActivityManager _activityManager;
+
     /// <summary>
-    /// Activity log controller.
+    /// Initializes a new instance of the <see cref="ActivityLogController"/> class.
     /// </summary>
-    [Route("System/ActivityLog")]
-    [Authorize(Policy = Policies.RequiresElevation)]
-    public class ActivityLogController : BaseJellyfinApiController
+    /// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param>
+    public ActivityLogController(IActivityManager activityManager)
     {
-        private readonly IActivityManager _activityManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ActivityLogController"/> class.
-        /// </summary>
-        /// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param>
-        public ActivityLogController(IActivityManager activityManager)
-        {
-            _activityManager = activityManager;
-        }
+        _activityManager = activityManager;
+    }
 
-        /// <summary>
-        /// Gets activity log entries.
-        /// </summary>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="minDate">Optional. The minimum date. Format = ISO.</param>
-        /// <param name="hasUserId">Optional. Filter log entries if it has user id, or not.</param>
-        /// <response code="200">Activity log returned.</response>
-        /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns>
-        [HttpGet("Entries")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<QueryResult<ActivityLogEntry>>> GetLogEntries(
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] DateTime? minDate,
-            [FromQuery] bool? hasUserId)
+    /// <summary>
+    /// Gets activity log entries.
+    /// </summary>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="minDate">Optional. The minimum date. Format = ISO.</param>
+    /// <param name="hasUserId">Optional. Filter log entries if it has user id, or not.</param>
+    /// <response code="200">Activity log returned.</response>
+    /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns>
+    [HttpGet("Entries")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<QueryResult<ActivityLogEntry>>> GetLogEntries(
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] DateTime? minDate,
+        [FromQuery] bool? hasUserId)
+    {
+        return await _activityManager.GetPagedResultAsync(new ActivityLogQuery
         {
-            return await _activityManager.GetPagedResultAsync(new ActivityLogQuery
-            {
-                Skip = startIndex,
-                Limit = limit,
-                MinDate = minDate,
-                HasUserId = hasUserId
-            }).ConfigureAwait(false);
-        }
+            Skip = startIndex,
+            Limit = limit,
+            MinDate = minDate,
+            HasUserId = hasUserId
+        }).ConfigureAwait(false);
     }
 }

+ 54 - 55
Jellyfin.Api/Controllers/ApiKeyController.cs

@@ -7,70 +7,69 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Authentication controller.
+/// </summary>
+[Route("Auth")]
+public class ApiKeyController : BaseJellyfinApiController
 {
+    private readonly IAuthenticationManager _authenticationManager;
+
     /// <summary>
-    /// Authentication controller.
+    /// Initializes a new instance of the <see cref="ApiKeyController"/> class.
     /// </summary>
-    [Route("Auth")]
-    public class ApiKeyController : BaseJellyfinApiController
+    /// <param name="authenticationManager">Instance of <see cref="IAuthenticationManager"/> interface.</param>
+    public ApiKeyController(IAuthenticationManager authenticationManager)
     {
-        private readonly IAuthenticationManager _authenticationManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ApiKeyController"/> class.
-        /// </summary>
-        /// <param name="authenticationManager">Instance of <see cref="IAuthenticationManager"/> interface.</param>
-        public ApiKeyController(IAuthenticationManager authenticationManager)
-        {
-            _authenticationManager = authenticationManager;
-        }
+        _authenticationManager = authenticationManager;
+    }
 
-        /// <summary>
-        /// Get all keys.
-        /// </summary>
-        /// <response code="200">Api keys retrieved.</response>
-        /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns>
-        [HttpGet("Keys")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys()
-        {
-            var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false);
+    /// <summary>
+    /// Get all keys.
+    /// </summary>
+    /// <response code="200">Api keys retrieved.</response>
+    /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns>
+    [HttpGet("Keys")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys()
+    {
+        var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false);
 
-            return new QueryResult<AuthenticationInfo>(keys);
-        }
+        return new QueryResult<AuthenticationInfo>(keys);
+    }
 
-        /// <summary>
-        /// Create a new api key.
-        /// </summary>
-        /// <param name="app">Name of the app using the authentication key.</param>
-        /// <response code="204">Api key created.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Keys")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> CreateKey([FromQuery, Required] string app)
-        {
-            await _authenticationManager.CreateApiKey(app).ConfigureAwait(false);
+    /// <summary>
+    /// Create a new api key.
+    /// </summary>
+    /// <param name="app">Name of the app using the authentication key.</param>
+    /// <response code="204">Api key created.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Keys")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> CreateKey([FromQuery, Required] string app)
+    {
+        await _authenticationManager.CreateApiKey(app).ConfigureAwait(false);
 
-            return NoContent();
-        }
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Remove an api key.
-        /// </summary>
-        /// <param name="key">The access token to delete.</param>
-        /// <response code="204">Api key deleted.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Keys/{key}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> RevokeKey([FromRoute, Required] string key)
-        {
-            await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false);
+    /// <summary>
+    /// Remove an api key.
+    /// </summary>
+    /// <param name="key">The access token to delete.</param>
+    /// <response code="204">Api key deleted.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Keys/{key}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> RevokeKey([FromRoute, Required] string key)
+    {
+        await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false);
 
-            return NoContent();
-        }
+        return NoContent();
     }
 }

+ 404 - 405
Jellyfin.Api/Controllers/ArtistsController.cs

@@ -17,464 +17,463 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The artists controller.
+/// </summary>
+[Route("Artists")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class ArtistsController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IUserManager _userManager;
+    private readonly IDtoService _dtoService;
+
     /// <summary>
-    /// The artists controller.
+    /// Initializes a new instance of the <see cref="ArtistsController"/> class.
     /// </summary>
-    [Route("Artists")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class ArtistsController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    public ArtistsController(
+        ILibraryManager libraryManager,
+        IUserManager userManager,
+        IDtoService dtoService)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-        private readonly IDtoService _dtoService;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ArtistsController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        public ArtistsController(
-            ILibraryManager libraryManager,
-            IUserManager userManager,
-            IDtoService dtoService)
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+        _dtoService = dtoService;
+    }
+
+    /// <summary>
+    /// Gets all artists from a given item, folder, or the entire library.
+    /// </summary>
+    /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="searchTerm">Optional. Search term.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+    /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+    /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
+    /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+    /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+    /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
+    /// <param name="enableUserData">Optional, include user data.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+    /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
+    /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+    /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+    /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+    /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+    /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
+    /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <param name="enableTotalRecordCount">Total record count.</param>
+    /// <response code="200">Artists returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the artists.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetArtists(
+        [FromQuery] double? minCommunityRating,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] string? searchTerm,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery] bool? isFavorite,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] string? person,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+        [FromQuery] Guid? userId,
+        [FromQuery] string? nameStartsWithOrGreater,
+        [FromQuery] string? nameStartsWith,
+        [FromQuery] string? nameLessThan,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery] bool? enableImages = true,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+        User? user = null;
+        BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
+
+        if (userId.HasValue && !userId.Equals(default))
         {
-            _libraryManager = libraryManager;
-            _userManager = userManager;
-            _dtoService = dtoService;
+            user = _userManager.GetUserById(userId.Value);
         }
 
-        /// <summary>
-        /// Gets all artists from a given item, folder, or the entire library.
-        /// </summary>
-        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="searchTerm">Optional. Search term.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
-        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
-        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
-        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
-        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
-        /// <param name="enableUserData">Optional, include user data.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
-        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
-        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <param name="enableTotalRecordCount">Total record count.</param>
-        /// <response code="200">Artists returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the artists.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetArtists(
-            [FromQuery] double? minCommunityRating,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] string? searchTerm,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
-            [FromQuery] bool? isFavorite,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery] bool? enableImages = true,
-            [FromQuery] bool enableTotalRecordCount = true)
+        var query = new InternalItemsQuery(user)
         {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+            ExcludeItemTypes = excludeItemTypes,
+            IncludeItemTypes = includeItemTypes,
+            MediaTypes = mediaTypes,
+            StartIndex = startIndex,
+            Limit = limit,
+            IsFavorite = isFavorite,
+            NameLessThan = nameLessThan,
+            NameStartsWith = nameStartsWith,
+            NameStartsWithOrGreater = nameStartsWithOrGreater,
+            Tags = tags,
+            OfficialRatings = officialRatings,
+            Genres = genres,
+            GenreIds = genreIds,
+            StudioIds = studioIds,
+            Person = person,
+            PersonIds = personIds,
+            PersonTypes = personTypes,
+            Years = years,
+            MinCommunityRating = minCommunityRating,
+            DtoOptions = dtoOptions,
+            SearchTerm = searchTerm,
+            EnableTotalRecordCount = enableTotalRecordCount,
+            OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
+        };
 
-            User? user = null;
-            BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
-
-            if (userId.HasValue && !userId.Equals(default))
+        if (parentId.HasValue)
+        {
+            if (parentItem is Folder)
             {
-                user = _userManager.GetUserById(userId.Value);
+                query.AncestorIds = new[] { parentId.Value };
             }
-
-            var query = new InternalItemsQuery(user)
+            else
             {
-                ExcludeItemTypes = excludeItemTypes,
-                IncludeItemTypes = includeItemTypes,
-                MediaTypes = mediaTypes,
-                StartIndex = startIndex,
-                Limit = limit,
-                IsFavorite = isFavorite,
-                NameLessThan = nameLessThan,
-                NameStartsWith = nameStartsWith,
-                NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = tags,
-                OfficialRatings = officialRatings,
-                Genres = genres,
-                GenreIds = genreIds,
-                StudioIds = studioIds,
-                Person = person,
-                PersonIds = personIds,
-                PersonTypes = personTypes,
-                Years = years,
-                MinCommunityRating = minCommunityRating,
-                DtoOptions = dtoOptions,
-                SearchTerm = searchTerm,
-                EnableTotalRecordCount = enableTotalRecordCount,
-                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
-            };
-
-            if (parentId.HasValue)
+                query.ItemIds = new[] { parentId.Value };
+            }
+        }
+
+        // Studios
+        if (studios.Length != 0)
+        {
+            query.StudioIds = studios.Select(i =>
             {
-                if (parentItem is Folder)
+                try
                 {
-                    query.AncestorIds = new[] { parentId.Value };
+                    return _libraryManager.GetStudio(i);
                 }
-                else
+                catch
                 {
-                    query.ItemIds = new[] { parentId.Value };
+                    return null;
                 }
-            }
+            }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+        }
 
-            // Studios
-            if (studios.Length != 0)
+        foreach (var filter in filters)
+        {
+            switch (filter)
             {
-                query.StudioIds = studios.Select(i =>
-                {
-                    try
-                    {
-                        return _libraryManager.GetStudio(i);
-                    }
-                    catch
-                    {
-                        return null;
-                    }
-                }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+                case ItemFilter.Dislikes:
+                    query.IsLiked = false;
+                    break;
+                case ItemFilter.IsFavorite:
+                    query.IsFavorite = true;
+                    break;
+                case ItemFilter.IsFavoriteOrLikes:
+                    query.IsFavoriteOrLiked = true;
+                    break;
+                case ItemFilter.IsFolder:
+                    query.IsFolder = true;
+                    break;
+                case ItemFilter.IsNotFolder:
+                    query.IsFolder = false;
+                    break;
+                case ItemFilter.IsPlayed:
+                    query.IsPlayed = true;
+                    break;
+                case ItemFilter.IsResumable:
+                    query.IsResumable = true;
+                    break;
+                case ItemFilter.IsUnplayed:
+                    query.IsPlayed = false;
+                    break;
+                case ItemFilter.Likes:
+                    query.IsLiked = true;
+                    break;
             }
+        }
 
-            foreach (var filter in filters)
+        var result = _libraryManager.GetArtists(query);
+
+        var dtos = result.Items.Select(i =>
+        {
+            var (baseItem, itemCounts) = i;
+            var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+            if (includeItemTypes.Length != 0)
             {
-                switch (filter)
-                {
-                    case ItemFilter.Dislikes:
-                        query.IsLiked = false;
-                        break;
-                    case ItemFilter.IsFavorite:
-                        query.IsFavorite = true;
-                        break;
-                    case ItemFilter.IsFavoriteOrLikes:
-                        query.IsFavoriteOrLiked = true;
-                        break;
-                    case ItemFilter.IsFolder:
-                        query.IsFolder = true;
-                        break;
-                    case ItemFilter.IsNotFolder:
-                        query.IsFolder = false;
-                        break;
-                    case ItemFilter.IsPlayed:
-                        query.IsPlayed = true;
-                        break;
-                    case ItemFilter.IsResumable:
-                        query.IsResumable = true;
-                        break;
-                    case ItemFilter.IsUnplayed:
-                        query.IsPlayed = false;
-                        break;
-                    case ItemFilter.Likes:
-                        query.IsLiked = true;
-                        break;
-                }
+                dto.ChildCount = itemCounts.ItemCount;
+                dto.ProgramCount = itemCounts.ProgramCount;
+                dto.SeriesCount = itemCounts.SeriesCount;
+                dto.EpisodeCount = itemCounts.EpisodeCount;
+                dto.MovieCount = itemCounts.MovieCount;
+                dto.TrailerCount = itemCounts.TrailerCount;
+                dto.AlbumCount = itemCounts.AlbumCount;
+                dto.SongCount = itemCounts.SongCount;
+                dto.ArtistCount = itemCounts.ArtistCount;
             }
 
-            var result = _libraryManager.GetArtists(query);
+            return dto;
+        });
 
-            var dtos = result.Items.Select(i =>
-            {
-                var (baseItem, itemCounts) = i;
-                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+        return new QueryResult<BaseItemDto>(
+            query.StartIndex,
+            result.TotalRecordCount,
+            dtos.ToArray());
+    }
 
-                if (includeItemTypes.Length != 0)
-                {
-                    dto.ChildCount = itemCounts.ItemCount;
-                    dto.ProgramCount = itemCounts.ProgramCount;
-                    dto.SeriesCount = itemCounts.SeriesCount;
-                    dto.EpisodeCount = itemCounts.EpisodeCount;
-                    dto.MovieCount = itemCounts.MovieCount;
-                    dto.TrailerCount = itemCounts.TrailerCount;
-                    dto.AlbumCount = itemCounts.AlbumCount;
-                    dto.SongCount = itemCounts.SongCount;
-                    dto.ArtistCount = itemCounts.ArtistCount;
-                }
+    /// <summary>
+    /// Gets all album artists from a given item, folder, or the entire library.
+    /// </summary>
+    /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="searchTerm">Optional. Search term.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+    /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+    /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
+    /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+    /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+    /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
+    /// <param name="enableUserData">Optional, include user data.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+    /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
+    /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+    /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+    /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+    /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+    /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
+    /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <param name="enableTotalRecordCount">Total record count.</param>
+    /// <response code="200">Album artists returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the album artists.</returns>
+    [HttpGet("AlbumArtists")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists(
+        [FromQuery] double? minCommunityRating,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] string? searchTerm,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery] bool? isFavorite,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] string? person,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+        [FromQuery] Guid? userId,
+        [FromQuery] string? nameStartsWithOrGreater,
+        [FromQuery] string? nameStartsWith,
+        [FromQuery] string? nameLessThan,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery] bool? enableImages = true,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-                return dto;
-            });
+        User? user = null;
+        BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
 
-            return new QueryResult<BaseItemDto>(
-                query.StartIndex,
-                result.TotalRecordCount,
-                dtos.ToArray());
+        if (userId.HasValue && !userId.Equals(default))
+        {
+            user = _userManager.GetUserById(userId.Value);
         }
 
-        /// <summary>
-        /// Gets all album artists from a given item, folder, or the entire library.
-        /// </summary>
-        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="searchTerm">Optional. Search term.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
-        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
-        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
-        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
-        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
-        /// <param name="enableUserData">Optional, include user data.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
-        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
-        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <param name="enableTotalRecordCount">Total record count.</param>
-        /// <response code="200">Album artists returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the album artists.</returns>
-        [HttpGet("AlbumArtists")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists(
-            [FromQuery] double? minCommunityRating,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] string? searchTerm,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
-            [FromQuery] bool? isFavorite,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery] bool? enableImages = true,
-            [FromQuery] bool enableTotalRecordCount = true)
+        var query = new InternalItemsQuery(user)
         {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-
-            User? user = null;
-            BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
+            ExcludeItemTypes = excludeItemTypes,
+            IncludeItemTypes = includeItemTypes,
+            MediaTypes = mediaTypes,
+            StartIndex = startIndex,
+            Limit = limit,
+            IsFavorite = isFavorite,
+            NameLessThan = nameLessThan,
+            NameStartsWith = nameStartsWith,
+            NameStartsWithOrGreater = nameStartsWithOrGreater,
+            Tags = tags,
+            OfficialRatings = officialRatings,
+            Genres = genres,
+            GenreIds = genreIds,
+            StudioIds = studioIds,
+            Person = person,
+            PersonIds = personIds,
+            PersonTypes = personTypes,
+            Years = years,
+            MinCommunityRating = minCommunityRating,
+            DtoOptions = dtoOptions,
+            SearchTerm = searchTerm,
+            EnableTotalRecordCount = enableTotalRecordCount,
+            OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
+        };
 
-            if (userId.HasValue && !userId.Equals(default))
+        if (parentId.HasValue)
+        {
+            if (parentItem is Folder)
             {
-                user = _userManager.GetUserById(userId.Value);
+                query.AncestorIds = new[] { parentId.Value };
             }
-
-            var query = new InternalItemsQuery(user)
+            else
             {
-                ExcludeItemTypes = excludeItemTypes,
-                IncludeItemTypes = includeItemTypes,
-                MediaTypes = mediaTypes,
-                StartIndex = startIndex,
-                Limit = limit,
-                IsFavorite = isFavorite,
-                NameLessThan = nameLessThan,
-                NameStartsWith = nameStartsWith,
-                NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = tags,
-                OfficialRatings = officialRatings,
-                Genres = genres,
-                GenreIds = genreIds,
-                StudioIds = studioIds,
-                Person = person,
-                PersonIds = personIds,
-                PersonTypes = personTypes,
-                Years = years,
-                MinCommunityRating = minCommunityRating,
-                DtoOptions = dtoOptions,
-                SearchTerm = searchTerm,
-                EnableTotalRecordCount = enableTotalRecordCount,
-                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
-            };
-
-            if (parentId.HasValue)
+                query.ItemIds = new[] { parentId.Value };
+            }
+        }
+
+        // Studios
+        if (studios.Length != 0)
+        {
+            query.StudioIds = studios.Select(i =>
             {
-                if (parentItem is Folder)
+                try
                 {
-                    query.AncestorIds = new[] { parentId.Value };
+                    return _libraryManager.GetStudio(i);
                 }
-                else
+                catch
                 {
-                    query.ItemIds = new[] { parentId.Value };
+                    return null;
                 }
-            }
+            }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+        }
 
-            // Studios
-            if (studios.Length != 0)
+        foreach (var filter in filters)
+        {
+            switch (filter)
             {
-                query.StudioIds = studios.Select(i =>
-                {
-                    try
-                    {
-                        return _libraryManager.GetStudio(i);
-                    }
-                    catch
-                    {
-                        return null;
-                    }
-                }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+                case ItemFilter.Dislikes:
+                    query.IsLiked = false;
+                    break;
+                case ItemFilter.IsFavorite:
+                    query.IsFavorite = true;
+                    break;
+                case ItemFilter.IsFavoriteOrLikes:
+                    query.IsFavoriteOrLiked = true;
+                    break;
+                case ItemFilter.IsFolder:
+                    query.IsFolder = true;
+                    break;
+                case ItemFilter.IsNotFolder:
+                    query.IsFolder = false;
+                    break;
+                case ItemFilter.IsPlayed:
+                    query.IsPlayed = true;
+                    break;
+                case ItemFilter.IsResumable:
+                    query.IsResumable = true;
+                    break;
+                case ItemFilter.IsUnplayed:
+                    query.IsPlayed = false;
+                    break;
+                case ItemFilter.Likes:
+                    query.IsLiked = true;
+                    break;
             }
+        }
 
-            foreach (var filter in filters)
-            {
-                switch (filter)
-                {
-                    case ItemFilter.Dislikes:
-                        query.IsLiked = false;
-                        break;
-                    case ItemFilter.IsFavorite:
-                        query.IsFavorite = true;
-                        break;
-                    case ItemFilter.IsFavoriteOrLikes:
-                        query.IsFavoriteOrLiked = true;
-                        break;
-                    case ItemFilter.IsFolder:
-                        query.IsFolder = true;
-                        break;
-                    case ItemFilter.IsNotFolder:
-                        query.IsFolder = false;
-                        break;
-                    case ItemFilter.IsPlayed:
-                        query.IsPlayed = true;
-                        break;
-                    case ItemFilter.IsResumable:
-                        query.IsResumable = true;
-                        break;
-                    case ItemFilter.IsUnplayed:
-                        query.IsPlayed = false;
-                        break;
-                    case ItemFilter.Likes:
-                        query.IsLiked = true;
-                        break;
-                }
-            }
+        var result = _libraryManager.GetAlbumArtists(query);
 
-            var result = _libraryManager.GetAlbumArtists(query);
+        var dtos = result.Items.Select(i =>
+        {
+            var (baseItem, itemCounts) = i;
+            var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
 
-            var dtos = result.Items.Select(i =>
+            if (includeItemTypes.Length != 0)
             {
-                var (baseItem, itemCounts) = i;
-                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
-                if (includeItemTypes.Length != 0)
-                {
-                    dto.ChildCount = itemCounts.ItemCount;
-                    dto.ProgramCount = itemCounts.ProgramCount;
-                    dto.SeriesCount = itemCounts.SeriesCount;
-                    dto.EpisodeCount = itemCounts.EpisodeCount;
-                    dto.MovieCount = itemCounts.MovieCount;
-                    dto.TrailerCount = itemCounts.TrailerCount;
-                    dto.AlbumCount = itemCounts.AlbumCount;
-                    dto.SongCount = itemCounts.SongCount;
-                    dto.ArtistCount = itemCounts.ArtistCount;
-                }
-
-                return dto;
-            });
+                dto.ChildCount = itemCounts.ItemCount;
+                dto.ProgramCount = itemCounts.ProgramCount;
+                dto.SeriesCount = itemCounts.SeriesCount;
+                dto.EpisodeCount = itemCounts.EpisodeCount;
+                dto.MovieCount = itemCounts.MovieCount;
+                dto.TrailerCount = itemCounts.TrailerCount;
+                dto.AlbumCount = itemCounts.AlbumCount;
+                dto.SongCount = itemCounts.SongCount;
+                dto.ArtistCount = itemCounts.ArtistCount;
+            }
 
-            return new QueryResult<BaseItemDto>(
-                query.StartIndex,
-                result.TotalRecordCount,
-                dtos.ToArray());
-        }
+            return dto;
+        });
 
-        /// <summary>
-        /// Gets an artist by name.
-        /// </summary>
-        /// <param name="name">Studio name.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <response code="200">Artist returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the artist.</returns>
-        [HttpGet("{name}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId)
-        {
-            var dtoOptions = new DtoOptions().AddClientFields(User);
+        return new QueryResult<BaseItemDto>(
+            query.StartIndex,
+            result.TotalRecordCount,
+            dtos.ToArray());
+    }
 
-            var item = _libraryManager.GetArtist(name, dtoOptions);
+    /// <summary>
+    /// Gets an artist by name.
+    /// </summary>
+    /// <param name="name">Studio name.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <response code="200">Artist returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the artist.</returns>
+    [HttpGet("{name}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId)
+    {
+        var dtoOptions = new DtoOptions().AddClientFields(User);
 
-            if (userId.HasValue && !userId.Value.Equals(default))
-            {
-                var user = _userManager.GetUserById(userId.Value);
+        var item = _libraryManager.GetArtist(name, dtoOptions);
 
-                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
-            }
+        if (userId.HasValue && !userId.Value.Equals(default))
+        {
+            var user = _userManager.GetUserById(userId.Value);
 
-            return _dtoService.GetBaseItemDto(item, dtoOptions);
+            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
         }
+
+        return _dtoService.GetBaseItemDto(item, dtoOptions);
     }
 }

+ 339 - 340
Jellyfin.Api/Controllers/AudioController.cs

@@ -10,355 +10,354 @@ using MediaBrowser.Model.Dlna;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The audio controller.
+/// </summary>
+// TODO: In order to authenticate this in the future, Dlna playback will require updating
+public class AudioController : BaseJellyfinApiController
 {
+    private readonly AudioHelper _audioHelper;
+
+    private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive;
+
     /// <summary>
-    /// The audio controller.
+    /// Initializes a new instance of the <see cref="AudioController"/> class.
     /// </summary>
-    // TODO: In order to authenticate this in the future, Dlna playback will require updating
-    public class AudioController : BaseJellyfinApiController
+    /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param>
+    public AudioController(AudioHelper audioHelper)
     {
-        private readonly AudioHelper _audioHelper;
-
-        private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="AudioController"/> class.
-        /// </summary>
-        /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param>
-        public AudioController(AudioHelper audioHelper)
-        {
-            _audioHelper = audioHelper;
-        }
+        _audioHelper = audioHelper;
+    }
 
-        /// <summary>
-        /// Gets an audio stream.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="container">The audio container.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Audio stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("{itemId}/stream", Name = "GetAudioStream")]
-        [HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesAudioFile]
-        public async Task<ActionResult> GetAudioStream(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] string? container,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string>? streamOptions)
+    /// <summary>
+    /// Gets an audio stream.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="container">The audio container.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Audio stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("{itemId}/stream", Name = "GetAudioStream")]
+    [HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesAudioFile]
+    public async Task<ActionResult> GetAudioStream(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] string? container,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string>? streamOptions)
+    {
+        StreamingRequestDto streamingRequest = new StreamingRequestDto
         {
-            StreamingRequestDto streamingRequest = new StreamingRequestDto
-            {
-                Id = itemId,
-                Container = container,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Static,
-                StreamOptions = streamOptions
-            };
+            Id = itemId,
+            Container = container,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Static,
+            StreamOptions = streamOptions
+        };
 
-            return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
-        }
+        return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Gets an audio stream.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="container">The audio container.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Audio stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")]
-        [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesAudioFile]
-        public async Task<ActionResult> GetAudioStreamByContainer(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] string container,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string>? streamOptions)
+    /// <summary>
+    /// Gets an audio stream.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="container">The audio container.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Audio stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")]
+    [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesAudioFile]
+    public async Task<ActionResult> GetAudioStreamByContainer(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] string container,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string>? streamOptions)
+    {
+        StreamingRequestDto streamingRequest = new StreamingRequestDto
         {
-            StreamingRequestDto streamingRequest = new StreamingRequestDto
-            {
-                Id = itemId,
-                Container = container,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Static,
-                StreamOptions = streamOptions
-            };
+            Id = itemId,
+            Container = container,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Static,
+            StreamOptions = streamOptions
+        };
 
-            return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
-        }
+        return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
     }
 }

+ 42 - 43
Jellyfin.Api/Controllers/BrandingController.cs

@@ -4,54 +4,53 @@ using MediaBrowser.Model.Branding;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Branding controller.
+/// </summary>
+public class BrandingController : BaseJellyfinApiController
 {
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+
     /// <summary>
-    /// Branding controller.
+    /// Initializes a new instance of the <see cref="BrandingController"/> class.
     /// </summary>
-    public class BrandingController : BaseJellyfinApiController
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    public BrandingController(IServerConfigurationManager serverConfigurationManager)
     {
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="BrandingController"/> class.
-        /// </summary>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        public BrandingController(IServerConfigurationManager serverConfigurationManager)
-        {
-            _serverConfigurationManager = serverConfigurationManager;
-        }
+        _serverConfigurationManager = serverConfigurationManager;
+    }
 
-        /// <summary>
-        /// Gets branding configuration.
-        /// </summary>
-        /// <response code="200">Branding configuration returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns>
-        [HttpGet("Configuration")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<BrandingOptions> GetBrandingOptions()
-        {
-            return _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
-        }
+    /// <summary>
+    /// Gets branding configuration.
+    /// </summary>
+    /// <response code="200">Branding configuration returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns>
+    [HttpGet("Configuration")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<BrandingOptions> GetBrandingOptions()
+    {
+        return _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+    }
 
-        /// <summary>
-        /// Gets branding css.
-        /// </summary>
-        /// <response code="200">Branding css returned.</response>
-        /// <response code="204">No branding css configured.</response>
-        /// <returns>
-        /// An <see cref="OkResult"/> containing the branding css if exist,
-        /// or a <see cref="NoContentResult"/> if the css is not configured.
-        /// </returns>
-        [HttpGet("Css")]
-        [HttpGet("Css.css", Name = "GetBrandingCss_2")]
-        [Produces("text/css")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult<string> GetBrandingCss()
-        {
-            var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
-            return options.CustomCss ?? string.Empty;
-        }
+    /// <summary>
+    /// Gets branding css.
+    /// </summary>
+    /// <response code="200">Branding css returned.</response>
+    /// <response code="204">No branding css configured.</response>
+    /// <returns>
+    /// An <see cref="OkResult"/> containing the branding css if exist,
+    /// or a <see cref="NoContentResult"/> if the css is not configured.
+    /// </returns>
+    [HttpGet("Css")]
+    [HttpGet("Css.css", Name = "GetBrandingCss_2")]
+    [Produces("text/css")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult<string> GetBrandingCss()
+    {
+        var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+        return options.CustomCss ?? string.Empty;
     }
 }

+ 207 - 208
Jellyfin.Api/Controllers/ChannelsController.cs

@@ -18,234 +18,233 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Channels Controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class ChannelsController : BaseJellyfinApiController
 {
+    private readonly IChannelManager _channelManager;
+    private readonly IUserManager _userManager;
+
     /// <summary>
-    /// Channels Controller.
+    /// Initializes a new instance of the <see cref="ChannelsController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class ChannelsController : BaseJellyfinApiController
+    /// <param name="channelManager">Instance of the <see cref="IChannelManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    public ChannelsController(IChannelManager channelManager, IUserManager userManager)
     {
-        private readonly IChannelManager _channelManager;
-        private readonly IUserManager _userManager;
+        _channelManager = channelManager;
+        _userManager = userManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ChannelsController"/> class.
-        /// </summary>
-        /// <param name="channelManager">Instance of the <see cref="IChannelManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        public ChannelsController(IChannelManager channelManager, IUserManager userManager)
+    /// <summary>
+    /// Gets available channels.
+    /// </summary>
+    /// <param name="userId">User Id to filter by. Use <see cref="Guid.Empty"/> to not filter by user.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="supportsLatestItems">Optional. Filter by channels that support getting latest items.</param>
+    /// <param name="supportsMediaDeletion">Optional. Filter by channels that support media deletion.</param>
+    /// <param name="isFavorite">Optional. Filter by channels that are favorite.</param>
+    /// <response code="200">Channels returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the channels.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetChannels(
+        [FromQuery] Guid? userId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] bool? supportsLatestItems,
+        [FromQuery] bool? supportsMediaDeletion,
+        [FromQuery] bool? isFavorite)
+    {
+        return _channelManager.GetChannels(new ChannelQuery
         {
-            _channelManager = channelManager;
-            _userManager = userManager;
-        }
+            Limit = limit,
+            StartIndex = startIndex,
+            UserId = userId ?? Guid.Empty,
+            SupportsLatestItems = supportsLatestItems,
+            SupportsMediaDeletion = supportsMediaDeletion,
+            IsFavorite = isFavorite
+        });
+    }
 
-        /// <summary>
-        /// Gets available channels.
-        /// </summary>
-        /// <param name="userId">User Id to filter by. Use <see cref="Guid.Empty"/> to not filter by user.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="supportsLatestItems">Optional. Filter by channels that support getting latest items.</param>
-        /// <param name="supportsMediaDeletion">Optional. Filter by channels that support media deletion.</param>
-        /// <param name="isFavorite">Optional. Filter by channels that are favorite.</param>
-        /// <response code="200">Channels returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the channels.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetChannels(
-            [FromQuery] Guid? userId,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] bool? supportsLatestItems,
-            [FromQuery] bool? supportsMediaDeletion,
-            [FromQuery] bool? isFavorite)
-        {
-            return _channelManager.GetChannels(new ChannelQuery
-            {
-                Limit = limit,
-                StartIndex = startIndex,
-                UserId = userId ?? Guid.Empty,
-                SupportsLatestItems = supportsLatestItems,
-                SupportsMediaDeletion = supportsMediaDeletion,
-                IsFavorite = isFavorite
-            });
-        }
+    /// <summary>
+    /// Get all channel features.
+    /// </summary>
+    /// <response code="200">All channel features returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
+    [HttpGet("Features")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<ChannelFeatures>> GetAllChannelFeatures()
+    {
+        return _channelManager.GetAllChannelFeatures();
+    }
 
-        /// <summary>
-        /// Get all channel features.
-        /// </summary>
-        /// <response code="200">All channel features returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
-        [HttpGet("Features")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<ChannelFeatures>> GetAllChannelFeatures()
-        {
-            return _channelManager.GetAllChannelFeatures();
-        }
+    /// <summary>
+    /// Get channel features.
+    /// </summary>
+    /// <param name="channelId">Channel id.</param>
+    /// <response code="200">Channel features returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
+    [HttpGet("{channelId}/Features")]
+    public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId)
+    {
+        return _channelManager.GetChannelFeatures(channelId);
+    }
 
-        /// <summary>
-        /// Get channel features.
-        /// </summary>
-        /// <param name="channelId">Channel id.</param>
-        /// <response code="200">Channel features returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
-        [HttpGet("{channelId}/Features")]
-        public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId)
-        {
-            return _channelManager.GetChannelFeatures(channelId);
-        }
+    /// <summary>
+    /// Get channel items.
+    /// </summary>
+    /// <param name="channelId">Channel Id.</param>
+    /// <param name="folderId">Optional. Folder Id.</param>
+    /// <param name="userId">Optional. User Id.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <response code="200">Channel items returned.</response>
+    /// <returns>
+    /// A <see cref="Task"/> representing the request to get the channel items.
+    /// The task result contains an <see cref="OkResult"/> containing the channel items.
+    /// </returns>
+    [HttpGet("{channelId}/Items")]
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems(
+        [FromRoute, Required] Guid channelId,
+        [FromQuery] Guid? folderId,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-        /// <summary>
-        /// Get channel items.
-        /// </summary>
-        /// <param name="channelId">Channel Id.</param>
-        /// <param name="folderId">Optional. Folder Id.</param>
-        /// <param name="userId">Optional. User Id.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <response code="200">Channel items returned.</response>
-        /// <returns>
-        /// A <see cref="Task"/> representing the request to get the channel items.
-        /// The task result contains an <see cref="OkResult"/> containing the channel items.
-        /// </returns>
-        [HttpGet("{channelId}/Items")]
-        public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems(
-            [FromRoute, Required] Guid channelId,
-            [FromQuery] Guid? folderId,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
+        var query = new InternalItemsQuery(user)
         {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+            Limit = limit,
+            StartIndex = startIndex,
+            ChannelIds = new[] { channelId },
+            ParentId = folderId ?? Guid.Empty,
+            OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
+            DtoOptions = new DtoOptions { Fields = fields }
+        };
 
-            var query = new InternalItemsQuery(user)
-            {
-                Limit = limit,
-                StartIndex = startIndex,
-                ChannelIds = new[] { channelId },
-                ParentId = folderId ?? Guid.Empty,
-                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
-                DtoOptions = new DtoOptions { Fields = fields }
-            };
-
-            foreach (var filter in filters)
+        foreach (var filter in filters)
+        {
+            switch (filter)
             {
-                switch (filter)
-                {
-                    case ItemFilter.IsFolder:
-                        query.IsFolder = true;
-                        break;
-                    case ItemFilter.IsNotFolder:
-                        query.IsFolder = false;
-                        break;
-                    case ItemFilter.IsUnplayed:
-                        query.IsPlayed = false;
-                        break;
-                    case ItemFilter.IsPlayed:
-                        query.IsPlayed = true;
-                        break;
-                    case ItemFilter.IsFavorite:
-                        query.IsFavorite = true;
-                        break;
-                    case ItemFilter.IsResumable:
-                        query.IsResumable = true;
-                        break;
-                    case ItemFilter.Likes:
-                        query.IsLiked = true;
-                        break;
-                    case ItemFilter.Dislikes:
-                        query.IsLiked = false;
-                        break;
-                    case ItemFilter.IsFavoriteOrLikes:
-                        query.IsFavoriteOrLiked = true;
-                        break;
-                }
+                case ItemFilter.IsFolder:
+                    query.IsFolder = true;
+                    break;
+                case ItemFilter.IsNotFolder:
+                    query.IsFolder = false;
+                    break;
+                case ItemFilter.IsUnplayed:
+                    query.IsPlayed = false;
+                    break;
+                case ItemFilter.IsPlayed:
+                    query.IsPlayed = true;
+                    break;
+                case ItemFilter.IsFavorite:
+                    query.IsFavorite = true;
+                    break;
+                case ItemFilter.IsResumable:
+                    query.IsResumable = true;
+                    break;
+                case ItemFilter.Likes:
+                    query.IsLiked = true;
+                    break;
+                case ItemFilter.Dislikes:
+                    query.IsLiked = false;
+                    break;
+                case ItemFilter.IsFavoriteOrLikes:
+                    query.IsFavoriteOrLiked = true;
+                    break;
             }
-
-            return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false);
         }
 
-        /// <summary>
-        /// Gets latest channel items.
-        /// </summary>
-        /// <param name="userId">Optional. User Id.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param>
-        /// <response code="200">Latest channel items returned.</response>
-        /// <returns>
-        /// A <see cref="Task"/> representing the request to get the latest channel items.
-        /// The task result contains an <see cref="OkResult"/> containing the latest channel items.
-        /// </returns>
-        [HttpGet("Items/Latest")]
-        public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLatestChannelItems(
-            [FromQuery] Guid? userId,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+        return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false);
+    }
 
-            var query = new InternalItemsQuery(user)
-            {
-                Limit = limit,
-                StartIndex = startIndex,
-                ChannelIds = channelIds,
-                DtoOptions = new DtoOptions { Fields = fields }
-            };
+    /// <summary>
+    /// Gets latest channel items.
+    /// </summary>
+    /// <param name="userId">Optional. User Id.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param>
+    /// <response code="200">Latest channel items returned.</response>
+    /// <returns>
+    /// A <see cref="Task"/> representing the request to get the latest channel items.
+    /// The task result contains an <see cref="OkResult"/> containing the latest channel items.
+    /// </returns>
+    [HttpGet("Items/Latest")]
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLatestChannelItems(
+        [FromQuery] Guid? userId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-            foreach (var filter in filters)
+        var query = new InternalItemsQuery(user)
+        {
+            Limit = limit,
+            StartIndex = startIndex,
+            ChannelIds = channelIds,
+            DtoOptions = new DtoOptions { Fields = fields }
+        };
+
+        foreach (var filter in filters)
+        {
+            switch (filter)
             {
-                switch (filter)
-                {
-                    case ItemFilter.IsFolder:
-                        query.IsFolder = true;
-                        break;
-                    case ItemFilter.IsNotFolder:
-                        query.IsFolder = false;
-                        break;
-                    case ItemFilter.IsUnplayed:
-                        query.IsPlayed = false;
-                        break;
-                    case ItemFilter.IsPlayed:
-                        query.IsPlayed = true;
-                        break;
-                    case ItemFilter.IsFavorite:
-                        query.IsFavorite = true;
-                        break;
-                    case ItemFilter.IsResumable:
-                        query.IsResumable = true;
-                        break;
-                    case ItemFilter.Likes:
-                        query.IsLiked = true;
-                        break;
-                    case ItemFilter.Dislikes:
-                        query.IsLiked = false;
-                        break;
-                    case ItemFilter.IsFavoriteOrLikes:
-                        query.IsFavoriteOrLiked = true;
-                        break;
-                }
+                case ItemFilter.IsFolder:
+                    query.IsFolder = true;
+                    break;
+                case ItemFilter.IsNotFolder:
+                    query.IsFolder = false;
+                    break;
+                case ItemFilter.IsUnplayed:
+                    query.IsPlayed = false;
+                    break;
+                case ItemFilter.IsPlayed:
+                    query.IsPlayed = true;
+                    break;
+                case ItemFilter.IsFavorite:
+                    query.IsFavorite = true;
+                    break;
+                case ItemFilter.IsResumable:
+                    query.IsResumable = true;
+                    break;
+                case ItemFilter.Likes:
+                    query.IsLiked = true;
+                    break;
+                case ItemFilter.Dislikes:
+                    query.IsLiked = false;
+                    break;
+                case ItemFilter.IsFavoriteOrLikes:
+                    query.IsFavoriteOrLiked = true;
+                    break;
             }
-
-            return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false);
         }
+
+        return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false);
     }
 }

+ 53 - 54
Jellyfin.Api/Controllers/ClientLogController.cs

@@ -11,71 +11,70 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Client log controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class ClientLogController : BaseJellyfinApiController
 {
+    private const int MaxDocumentSize = 1_000_000;
+    private readonly IClientEventLogger _clientEventLogger;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+
     /// <summary>
-    /// Client log controller.
+    /// Initializes a new instance of the <see cref="ClientLogController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class ClientLogController : BaseJellyfinApiController
+    /// <param name="clientEventLogger">Instance of the <see cref="IClientEventLogger"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    public ClientLogController(
+        IClientEventLogger clientEventLogger,
+        IServerConfigurationManager serverConfigurationManager)
     {
-        private const int MaxDocumentSize = 1_000_000;
-        private readonly IClientEventLogger _clientEventLogger;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
+        _clientEventLogger = clientEventLogger;
+        _serverConfigurationManager = serverConfigurationManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ClientLogController"/> class.
-        /// </summary>
-        /// <param name="clientEventLogger">Instance of the <see cref="IClientEventLogger"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        public ClientLogController(
-            IClientEventLogger clientEventLogger,
-            IServerConfigurationManager serverConfigurationManager)
+    /// <summary>
+    /// Upload a document.
+    /// </summary>
+    /// <response code="200">Document saved.</response>
+    /// <response code="403">Event logging disabled.</response>
+    /// <response code="413">Upload size too large.</response>
+    /// <returns>Create response.</returns>
+    [HttpPost("Document")]
+    [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
+    [AcceptsFile(MediaTypeNames.Text.Plain)]
+    [RequestSizeLimit(MaxDocumentSize)]
+    public async Task<ActionResult<ClientLogDocumentResponseDto>> LogFile()
+    {
+        if (!_serverConfigurationManager.Configuration.AllowClientLogUpload)
         {
-            _clientEventLogger = clientEventLogger;
-            _serverConfigurationManager = serverConfigurationManager;
+            return Forbid();
         }
 
-        /// <summary>
-        /// Upload a document.
-        /// </summary>
-        /// <response code="200">Document saved.</response>
-        /// <response code="403">Event logging disabled.</response>
-        /// <response code="413">Upload size too large.</response>
-        /// <returns>Create response.</returns>
-        [HttpPost("Document")]
-        [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
-        [AcceptsFile(MediaTypeNames.Text.Plain)]
-        [RequestSizeLimit(MaxDocumentSize)]
-        public async Task<ActionResult<ClientLogDocumentResponseDto>> LogFile()
+        if (Request.ContentLength > MaxDocumentSize)
         {
-            if (!_serverConfigurationManager.Configuration.AllowClientLogUpload)
-            {
-                return Forbid();
-            }
-
-            if (Request.ContentLength > MaxDocumentSize)
-            {
-                // Manually validate to return proper status code.
-                return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes");
-            }
-
-            var (clientName, clientVersion) = GetRequestInformation();
-            var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body)
-                .ConfigureAwait(false);
-            return Ok(new ClientLogDocumentResponseDto(fileName));
+            // Manually validate to return proper status code.
+            return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes");
         }
 
-        private (string ClientName, string ClientVersion) GetRequestInformation()
-        {
-            var clientName = HttpContext.User.GetClient() ?? "unknown-client";
-            var clientVersion = HttpContext.User.GetIsApiKey()
-                ? "apikey"
-                : HttpContext.User.GetVersion() ?? "unknown-version";
+        var (clientName, clientVersion) = GetRequestInformation();
+        var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body)
+            .ConfigureAwait(false);
+        return Ok(new ClientLogDocumentResponseDto(fileName));
+    }
 
-            return (clientName, clientVersion);
-        }
+    private (string ClientName, string ClientVersion) GetRequestInformation()
+    {
+        var clientName = HttpContext.User.GetClient() ?? "unknown-client";
+        var clientVersion = HttpContext.User.GetIsApiKey()
+            ? "apikey"
+            : HttpContext.User.GetVersion() ?? "unknown-version";
+
+        return (clientName, clientVersion);
     }
 }

+ 83 - 84
Jellyfin.Api/Controllers/CollectionController.cs

@@ -11,101 +11,100 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The collection controller.
+/// </summary>
+[Route("Collections")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class CollectionController : BaseJellyfinApiController
 {
+    private readonly ICollectionManager _collectionManager;
+    private readonly IDtoService _dtoService;
+
     /// <summary>
-    /// The collection controller.
+    /// Initializes a new instance of the <see cref="CollectionController"/> class.
     /// </summary>
-    [Route("Collections")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class CollectionController : BaseJellyfinApiController
+    /// <param name="collectionManager">Instance of <see cref="ICollectionManager"/> interface.</param>
+    /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
+    public CollectionController(
+        ICollectionManager collectionManager,
+        IDtoService dtoService)
     {
-        private readonly ICollectionManager _collectionManager;
-        private readonly IDtoService _dtoService;
+        _collectionManager = collectionManager;
+        _dtoService = dtoService;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CollectionController"/> class.
-        /// </summary>
-        /// <param name="collectionManager">Instance of <see cref="ICollectionManager"/> interface.</param>
-        /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
-        public CollectionController(
-            ICollectionManager collectionManager,
-            IDtoService dtoService)
-        {
-            _collectionManager = collectionManager;
-            _dtoService = dtoService;
-        }
+    /// <summary>
+    /// Creates a new collection.
+    /// </summary>
+    /// <param name="name">The name of the collection.</param>
+    /// <param name="ids">Item Ids to add to the collection.</param>
+    /// <param name="parentId">Optional. Create the collection within a specific folder.</param>
+    /// <param name="isLocked">Whether or not to lock the new collection.</param>
+    /// <response code="200">Collection created.</response>
+    /// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns>
+    [HttpPost]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<CollectionCreationResult>> CreateCollection(
+        [FromQuery] string? name,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids,
+        [FromQuery] Guid? parentId,
+        [FromQuery] bool isLocked = false)
+    {
+        var userId = User.GetUserId();
 
-        /// <summary>
-        /// Creates a new collection.
-        /// </summary>
-        /// <param name="name">The name of the collection.</param>
-        /// <param name="ids">Item Ids to add to the collection.</param>
-        /// <param name="parentId">Optional. Create the collection within a specific folder.</param>
-        /// <param name="isLocked">Whether or not to lock the new collection.</param>
-        /// <response code="200">Collection created.</response>
-        /// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns>
-        [HttpPost]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<CollectionCreationResult>> CreateCollection(
-            [FromQuery] string? name,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids,
-            [FromQuery] Guid? parentId,
-            [FromQuery] bool isLocked = false)
+        var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions
         {
-            var userId = User.GetUserId();
-
-            var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions
-            {
-                IsLocked = isLocked,
-                Name = name,
-                ParentId = parentId,
-                ItemIdList = ids,
-                UserIds = new[] { userId }
-            }).ConfigureAwait(false);
+            IsLocked = isLocked,
+            Name = name,
+            ParentId = parentId,
+            ItemIdList = ids,
+            UserIds = new[] { userId }
+        }).ConfigureAwait(false);
 
-            var dtoOptions = new DtoOptions().AddClientFields(User);
+        var dtoOptions = new DtoOptions().AddClientFields(User);
 
-            var dto = _dtoService.GetBaseItemDto(item, dtoOptions);
+        var dto = _dtoService.GetBaseItemDto(item, dtoOptions);
 
-            return new CollectionCreationResult
-            {
-                Id = dto.Id
-            };
-        }
-
-        /// <summary>
-        /// Adds items to a collection.
-        /// </summary>
-        /// <param name="collectionId">The collection id.</param>
-        /// <param name="ids">Item ids, comma delimited.</param>
-        /// <response code="204">Items added to collection.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("{collectionId}/Items")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> AddToCollection(
-            [FromRoute, Required] Guid collectionId,
-            [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+        return new CollectionCreationResult
         {
-            await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
-            return NoContent();
-        }
+            Id = dto.Id
+        };
+    }
 
-        /// <summary>
-        /// Removes items from a collection.
-        /// </summary>
-        /// <param name="collectionId">The collection id.</param>
-        /// <param name="ids">Item ids, comma delimited.</param>
-        /// <response code="204">Items removed from collection.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpDelete("{collectionId}/Items")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> RemoveFromCollection(
-            [FromRoute, Required] Guid collectionId,
-            [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
-        {
-            await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false);
-            return NoContent();
-        }
+    /// <summary>
+    /// Adds items to a collection.
+    /// </summary>
+    /// <param name="collectionId">The collection id.</param>
+    /// <param name="ids">Item ids, comma delimited.</param>
+    /// <response code="204">Items added to collection.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("{collectionId}/Items")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> AddToCollection(
+        [FromRoute, Required] Guid collectionId,
+        [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+    {
+        await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Removes items from a collection.
+    /// </summary>
+    /// <param name="collectionId">The collection id.</param>
+    /// <param name="ids">Item ids, comma delimited.</param>
+    /// <response code="204">Items removed from collection.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpDelete("{collectionId}/Items")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> RemoveFromCollection(
+        [FromRoute, Required] Guid collectionId,
+        [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+    {
+        await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false);
+        return NoContent();
     }
 }

+ 104 - 105
Jellyfin.Api/Controllers/ConfigurationController.cs

@@ -13,124 +13,123 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Configuration Controller.
+/// </summary>
+[Route("System")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class ConfigurationController : BaseJellyfinApiController
 {
+    private readonly IServerConfigurationManager _configurationManager;
+    private readonly IMediaEncoder _mediaEncoder;
+
+    private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options;
+
     /// <summary>
-    /// Configuration Controller.
+    /// Initializes a new instance of the <see cref="ConfigurationController"/> class.
     /// </summary>
-    [Route("System")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class ConfigurationController : BaseJellyfinApiController
+    /// <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)
     {
-        private readonly IServerConfigurationManager _configurationManager;
-        private readonly IMediaEncoder _mediaEncoder;
+        _configurationManager = configurationManager;
+        _mediaEncoder = mediaEncoder;
+    }
 
-        private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options;
+    /// <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>
-        /// 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>
+    /// Updates application configuration.
+    /// </summary>
+    /// <param name="configuration">Configuration.</param>
+    /// <response code="204">Configuration updated.</response>
+    /// <returns>Update status.</returns>
+    [HttpPost("Configuration")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration)
+    {
+        _configurationManager.ReplaceConfiguration(configuration);
+        return NoContent();
+    }
 
-        /// <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>
+    /// 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)]
+    [ProducesFile(MediaTypeNames.Application.Json)]
+    public ActionResult<object> GetNamedConfiguration([FromRoute, Required] string key)
+    {
+        return _configurationManager.GetConfiguration(key);
+    }
 
-        /// <summary>
-        /// Updates application configuration.
-        /// </summary>
-        /// <param name="configuration">Configuration.</param>
-        /// <response code="204">Configuration updated.</response>
-        /// <returns>Update status.</returns>
-        [HttpPost("Configuration")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration)
-        {
-            _configurationManager.ReplaceConfiguration(configuration);
-            return NoContent();
-        }
+    /// <summary>
+    /// Updates named configuration.
+    /// </summary>
+    /// <param name="key">Configuration key.</param>
+    /// <param name="configuration">Configuration.</param>
+    /// <response code="204">Named configuration updated.</response>
+    /// <returns>Update status.</returns>
+    [HttpPost("Configuration/{key}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult UpdateNamedConfiguration([FromRoute, Required] string key, [FromBody, Required] JsonDocument configuration)
+    {
+        var configurationType = _configurationManager.GetConfigurationType(key);
+        var deserializedConfiguration = configuration.Deserialize(configurationType, _serializerOptions);
 
-        /// <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)]
-        [ProducesFile(MediaTypeNames.Application.Json)]
-        public ActionResult<object> GetNamedConfiguration([FromRoute, Required] string key)
+        if (deserializedConfiguration is null)
         {
-            return _configurationManager.GetConfiguration(key);
+            throw new ArgumentException("Body doesn't contain a valid configuration");
         }
 
-        /// <summary>
-        /// Updates named configuration.
-        /// </summary>
-        /// <param name="key">Configuration key.</param>
-        /// <param name="configuration">Configuration.</param>
-        /// <response code="204">Named configuration updated.</response>
-        /// <returns>Update status.</returns>
-        [HttpPost("Configuration/{key}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult UpdateNamedConfiguration([FromRoute, Required] string key, [FromBody, Required] JsonDocument configuration)
-        {
-            var configurationType = _configurationManager.GetConfigurationType(key);
-            var deserializedConfiguration = configuration.Deserialize(configurationType, _serializerOptions);
-
-            if (deserializedConfiguration is null)
-            {
-                throw new ArgumentException("Body doesn't contain a valid configuration");
-            }
-
-            _configurationManager.SaveConfiguration(key, deserializedConfiguration);
-            return NoContent();
-        }
+        _configurationManager.SaveConfiguration(key, deserializedConfiguration);
+        return NoContent();
+    }
 
-        /// <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>
+    /// 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="204">Media encoder path updated.</response>
-        /// <returns>Status.</returns>
-        [HttpPost("MediaEncoder/Path")]
-        [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath)
-        {
-            _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
-            return NoContent();
-        }
+    /// <summary>
+    /// Updates the path to the media encoder.
+    /// </summary>
+    /// <param name="mediaEncoderPath">Media encoder path form body.</param>
+    /// <response code="204">Media encoder path updated.</response>
+    /// <returns>Status.</returns>
+    [HttpPost("MediaEncoder/Path")]
+    [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath)
+    {
+        _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
+        return NoContent();
     }
 }

+ 78 - 79
Jellyfin.Api/Controllers/DashboardController.cs

@@ -14,103 +14,102 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The dashboard controller.
+/// </summary>
+[Route("")]
+public class DashboardController : BaseJellyfinApiController
 {
+    private readonly ILogger<DashboardController> _logger;
+    private readonly IPluginManager _pluginManager;
+
     /// <summary>
-    /// The dashboard controller.
+    /// Initializes a new instance of the <see cref="DashboardController"/> class.
     /// </summary>
-    [Route("")]
-    public class DashboardController : BaseJellyfinApiController
+    /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param>
+    /// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param>
+    public DashboardController(
+        ILogger<DashboardController> logger,
+        IPluginManager pluginManager)
     {
-        private readonly ILogger<DashboardController> _logger;
-        private readonly IPluginManager _pluginManager;
+        _logger = logger;
+        _pluginManager = pluginManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DashboardController"/> class.
-        /// </summary>
-        /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param>
-        /// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param>
-        public DashboardController(
-            ILogger<DashboardController> logger,
-            IPluginManager pluginManager)
-        {
-            _logger = logger;
-            _pluginManager = pluginManager;
-        }
+    /// <summary>
+    /// Gets the configuration pages.
+    /// </summary>
+    /// <param name="enableInMainMenu">Whether to enable in the main menu.</param>
+    /// <response code="200">ConfigurationPages returned.</response>
+    /// <response code="404">Server still loading.</response>
+    /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns>
+    [HttpGet("web/ConfigurationPages")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages(
+        [FromQuery] bool? enableInMainMenu)
+    {
+        var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList();
 
-        /// <summary>
-        /// Gets the configuration pages.
-        /// </summary>
-        /// <param name="enableInMainMenu">Whether to enable in the main menu.</param>
-        /// <response code="200">ConfigurationPages returned.</response>
-        /// <response code="404">Server still loading.</response>
-        /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns>
-        [HttpGet("web/ConfigurationPages")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages(
-            [FromQuery] bool? enableInMainMenu)
+        if (enableInMainMenu.HasValue)
         {
-            var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList();
-
-            if (enableInMainMenu.HasValue)
-            {
-                configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList();
-            }
-
-            return configPages;
+            configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList();
         }
 
-        /// <summary>
-        /// Gets a dashboard configuration page.
-        /// </summary>
-        /// <param name="name">The name of the page.</param>
-        /// <response code="200">ConfigurationPage returned.</response>
-        /// <response code="404">Plugin configuration page not found.</response>
-        /// <returns>The configuration page.</returns>
-        [HttpGet("web/ConfigurationPage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")]
-        public ActionResult GetDashboardConfigurationPage([FromQuery] string? name)
-        {
-            var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase));
-            if (altPage is null)
-            {
-                return NotFound();
-            }
-
-            IPlugin plugin = altPage.Item2;
-            string resourcePath = altPage.Item1.EmbeddedResourcePath;
-            Stream? stream = plugin.GetType().Assembly.GetManifestResourceStream(resourcePath);
-            if (stream is null)
-            {
-                _logger.LogError("Failed to get resource {Resource} from plugin {Plugin}", resourcePath, plugin.Name);
-                return NotFound();
-            }
+        return configPages;
+    }
 
-            return File(stream, MimeTypes.GetMimeType(resourcePath));
+    /// <summary>
+    /// Gets a dashboard configuration page.
+    /// </summary>
+    /// <param name="name">The name of the page.</param>
+    /// <response code="200">ConfigurationPage returned.</response>
+    /// <response code="404">Plugin configuration page not found.</response>
+    /// <returns>The configuration page.</returns>
+    [HttpGet("web/ConfigurationPage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")]
+    public ActionResult GetDashboardConfigurationPage([FromQuery] string? name)
+    {
+        var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase));
+        if (altPage is null)
+        {
+            return NotFound();
         }
 
-        private IEnumerable<ConfigurationPageInfo> GetConfigPages(LocalPlugin plugin)
+        IPlugin plugin = altPage.Item2;
+        string resourcePath = altPage.Item1.EmbeddedResourcePath;
+        Stream? stream = plugin.GetType().Assembly.GetManifestResourceStream(resourcePath);
+        if (stream is null)
         {
-            return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1));
+            _logger.LogError("Failed to get resource {Resource} from plugin {Plugin}", resourcePath, plugin.Name);
+            return NotFound();
         }
 
-        private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(LocalPlugin plugin)
-        {
-            if (plugin.Instance is not IHasWebPages hasWebPages)
-            {
-                return Enumerable.Empty<Tuple<PluginPageInfo, IPlugin>>();
-            }
+        return File(stream, MimeTypes.GetMimeType(resourcePath));
+    }
 
-            return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin.Instance));
-        }
+    private IEnumerable<ConfigurationPageInfo> GetConfigPages(LocalPlugin plugin)
+    {
+        return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1));
+    }
 
-        private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages()
+    private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(LocalPlugin plugin)
+    {
+        if (plugin.Instance is not IHasWebPages hasWebPages)
         {
-            return _pluginManager.Plugins.SelectMany(GetPluginPages);
+            return Enumerable.Empty<Tuple<PluginPageInfo, IPlugin>>();
         }
+
+        return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin.Instance));
+    }
+
+    private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages()
+    {
+        return _pluginManager.Plugins.SelectMany(GetPluginPages);
     }
 }

+ 104 - 105
Jellyfin.Api/Controllers/DevicesController.cs

@@ -13,129 +13,128 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Devices Controller.
+/// </summary>
+[Authorize(Policy = Policies.RequiresElevation)]
+public class DevicesController : BaseJellyfinApiController
 {
+    private readonly IDeviceManager _deviceManager;
+    private readonly ISessionManager _sessionManager;
+
     /// <summary>
-    /// Devices Controller.
+    /// Initializes a new instance of the <see cref="DevicesController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.RequiresElevation)]
-    public class DevicesController : BaseJellyfinApiController
+    /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
+    /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
+    public DevicesController(
+        IDeviceManager deviceManager,
+        ISessionManager sessionManager)
     {
-        private readonly IDeviceManager _deviceManager;
-        private readonly ISessionManager _sessionManager;
+        _deviceManager = deviceManager;
+        _sessionManager = 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="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
-        public DevicesController(
-            IDeviceManager deviceManager,
-            ISessionManager sessionManager)
-        {
-            _deviceManager = deviceManager;
-            _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]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<QueryResult<DeviceInfo>>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
+    {
+        return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false);
+    }
 
-        /// <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]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<QueryResult<DeviceInfo>>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
+    /// <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")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<DeviceInfo>> GetDeviceInfo([FromQuery, Required] string id)
+    {
+        var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false);
+        if (deviceInfo is null)
         {
-            return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false);
+            return NotFound();
         }
 
-        /// <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")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult<DeviceInfo>> GetDeviceInfo([FromQuery, Required] string id)
-        {
-            var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false);
-            if (deviceInfo is null)
-            {
-                return NotFound();
-            }
+        return deviceInfo;
+    }
 
-            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")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<DeviceOptions>> GetDeviceOptions([FromQuery, Required] string id)
+    {
+        var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false);
+        if (deviceInfo is null)
+        {
+            return NotFound();
         }
 
-        /// <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")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult<DeviceOptions>> GetDeviceOptions([FromQuery, Required] string id)
-        {
-            var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false);
-            if (deviceInfo is null)
-            {
-                return NotFound();
-            }
+        return deviceInfo;
+    }
 
-            return deviceInfo;
-        }
+    /// <summary>
+    /// Update device options.
+    /// </summary>
+    /// <param name="id">Device Id.</param>
+    /// <param name="deviceOptions">Device Options.</param>
+    /// <response code="204">Device options updated.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Options")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> UpdateDeviceOptions(
+        [FromQuery, Required] string id,
+        [FromBody, Required] DeviceOptionsDto deviceOptions)
+    {
+        await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Update device options.
-        /// </summary>
-        /// <param name="id">Device Id.</param>
-        /// <param name="deviceOptions">Device Options.</param>
-        /// <response code="204">Device options updated.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Options")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> UpdateDeviceOptions(
-            [FromQuery, Required] string id,
-            [FromBody, Required] DeviceOptionsDto deviceOptions)
+    /// <summary>
+    /// Deletes a device.
+    /// </summary>
+    /// <param name="id">Device Id.</param>
+    /// <response code="204">Device deleted.</response>
+    /// <response code="404">Device not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+    [HttpDelete]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id)
+    {
+        var existingDevice = await _deviceManager.GetDevice(id).ConfigureAwait(false);
+        if (existingDevice is null)
         {
-            await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false);
-            return NoContent();
+            return NotFound();
         }
 
-        /// <summary>
-        /// Deletes a device.
-        /// </summary>
-        /// <param name="id">Device Id.</param>
-        /// <response code="204">Device deleted.</response>
-        /// <response code="404">Device not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
-        [HttpDelete]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id)
-        {
-            var existingDevice = await _deviceManager.GetDevice(id).ConfigureAwait(false);
-            if (existingDevice is null)
-            {
-                return NotFound();
-            }
-
-            var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }).ConfigureAwait(false);
-
-            foreach (var session in sessions.Items)
-            {
-                await _sessionManager.Logout(session).ConfigureAwait(false);
-            }
+        var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }).ConfigureAwait(false);
 
-            return NoContent();
+        foreach (var session in sessions.Items)
+        {
+            await _sessionManager.Logout(session).ConfigureAwait(false);
         }
+
+        return NoContent();
     }
 }

+ 170 - 171
Jellyfin.Api/Controllers/DisplayPreferencesController.cs

@@ -14,201 +14,200 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Display Preferences Controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class DisplayPreferencesController : BaseJellyfinApiController
 {
+    private readonly IDisplayPreferencesManager _displayPreferencesManager;
+    private readonly ILogger<DisplayPreferencesController> _logger;
+
     /// <summary>
-    /// Display Preferences Controller.
+    /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class DisplayPreferencesController : BaseJellyfinApiController
+    /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param>
+    /// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param>
+    public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger)
     {
-        private readonly IDisplayPreferencesManager _displayPreferencesManager;
-        private readonly ILogger<DisplayPreferencesController> _logger;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class.
-        /// </summary>
-        /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param>
-        /// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param>
-        public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger)
+        _displayPreferencesManager = displayPreferencesManager;
+        _logger = logger;
+    }
+
+    /// <summary>
+    /// Get Display Preferences.
+    /// </summary>
+    /// <param name="displayPreferencesId">Display preferences id.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="client">Client.</param>
+    /// <response code="200">Display preferences retrieved.</response>
+    /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns>
+    [HttpGet("{displayPreferencesId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
+    public ActionResult<DisplayPreferencesDto> GetDisplayPreferences(
+        [FromRoute, Required] string displayPreferencesId,
+        [FromQuery, Required] Guid userId,
+        [FromQuery, Required] string client)
+    {
+        if (!Guid.TryParse(displayPreferencesId, out var itemId))
         {
-            _displayPreferencesManager = displayPreferencesManager;
-            _logger = logger;
+            itemId = displayPreferencesId.GetMD5();
         }
 
-        /// <summary>
-        /// Get Display Preferences.
-        /// </summary>
-        /// <param name="displayPreferencesId">Display preferences id.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="client">Client.</param>
-        /// <response code="200">Display preferences retrieved.</response>
-        /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns>
-        [HttpGet("{displayPreferencesId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
-        public ActionResult<DisplayPreferencesDto> GetDisplayPreferences(
-            [FromRoute, Required] string displayPreferencesId,
-            [FromQuery, Required] Guid userId,
-            [FromQuery, Required] string client)
-        {
-            if (!Guid.TryParse(displayPreferencesId, out var itemId))
-            {
-                itemId = displayPreferencesId.GetMD5();
-            }
+        var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
+        var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
+        itemPreferences.ItemId = itemId;
 
-            var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
-            var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
-            itemPreferences.ItemId = itemId;
+        var dto = new DisplayPreferencesDto
+        {
+            Client = displayPreferences.Client,
+            Id = displayPreferences.ItemId.ToString(),
+            SortBy = itemPreferences.SortBy,
+            SortOrder = itemPreferences.SortOrder,
+            IndexBy = displayPreferences.IndexBy?.ToString(),
+            RememberIndexing = itemPreferences.RememberIndexing,
+            RememberSorting = itemPreferences.RememberSorting,
+            ScrollDirection = displayPreferences.ScrollDirection,
+            ShowBackdrop = displayPreferences.ShowBackdrop,
+            ShowSidebar = displayPreferences.ShowSidebar
+        };
+
+        foreach (var homeSection in displayPreferences.HomeSections)
+        {
+            dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant();
+        }
 
-            var dto = new DisplayPreferencesDto
-            {
-                Client = displayPreferences.Client,
-                Id = displayPreferences.ItemId.ToString(),
-                SortBy = itemPreferences.SortBy,
-                SortOrder = itemPreferences.SortOrder,
-                IndexBy = displayPreferences.IndexBy?.ToString(),
-                RememberIndexing = itemPreferences.RememberIndexing,
-                RememberSorting = itemPreferences.RememberSorting,
-                ScrollDirection = displayPreferences.ScrollDirection,
-                ShowBackdrop = displayPreferences.ShowBackdrop,
-                ShowSidebar = displayPreferences.ShowSidebar
-            };
-
-            foreach (var homeSection in displayPreferences.HomeSections)
-            {
-                dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant();
-            }
+        dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant();
+        dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture);
+        dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture);
+        dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
+        dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
+        dto.CustomPrefs["dashboardTheme"] = displayPreferences.DashboardTheme;
 
-            dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant();
-            dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture);
-            dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture);
-            dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
-            dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
-            dto.CustomPrefs["dashboardTheme"] = displayPreferences.DashboardTheme;
+        // Load all custom display preferences
+        var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
+        foreach (var (key, value) in customDisplayPreferences)
+        {
+            dto.CustomPrefs.TryAdd(key, value);
+        }
 
-            // Load all custom display preferences
-            var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
-            foreach (var (key, value) in customDisplayPreferences)
-            {
-                dto.CustomPrefs.TryAdd(key, value);
-            }
+        // This will essentially be a noop if no changes have been made, but new prefs must be saved at least.
+        _displayPreferencesManager.SaveChanges();
 
-            // This will essentially be a noop if no changes have been made, but new prefs must be saved at least.
-            _displayPreferencesManager.SaveChanges();
+        return dto;
+    }
 
-            return dto;
+    /// <summary>
+    /// Update Display Preferences.
+    /// </summary>
+    /// <param name="displayPreferencesId">Display preferences id.</param>
+    /// <param name="userId">User Id.</param>
+    /// <param name="client">Client.</param>
+    /// <param name="displayPreferences">New Display Preferences object.</param>
+    /// <response code="204">Display preferences updated.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success.</returns>
+    [HttpPost("{displayPreferencesId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
+    public ActionResult UpdateDisplayPreferences(
+        [FromRoute, Required] string displayPreferencesId,
+        [FromQuery, Required] Guid userId,
+        [FromQuery, Required] string client,
+        [FromBody, Required] DisplayPreferencesDto displayPreferences)
+    {
+        HomeSectionType[] defaults =
+        {
+            HomeSectionType.SmallLibraryTiles,
+            HomeSectionType.Resume,
+            HomeSectionType.ResumeAudio,
+            HomeSectionType.ResumeBook,
+            HomeSectionType.LiveTv,
+            HomeSectionType.NextUp,
+            HomeSectionType.LatestMedia,
+            HomeSectionType.None,
+        };
+
+        if (!Guid.TryParse(displayPreferencesId, out var itemId))
+        {
+            itemId = displayPreferencesId.GetMD5();
         }
 
-        /// <summary>
-        /// Update Display Preferences.
-        /// </summary>
-        /// <param name="displayPreferencesId">Display preferences id.</param>
-        /// <param name="userId">User Id.</param>
-        /// <param name="client">Client.</param>
-        /// <param name="displayPreferences">New Display Preferences object.</param>
-        /// <response code="204">Display preferences updated.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success.</returns>
-        [HttpPost("{displayPreferencesId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
-        public ActionResult UpdateDisplayPreferences(
-            [FromRoute, Required] string displayPreferencesId,
-            [FromQuery, Required] Guid userId,
-            [FromQuery, Required] string client,
-            [FromBody, Required] DisplayPreferencesDto displayPreferences)
+        var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
+        existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null;
+        existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop;
+        existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar;
+
+        existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection;
+        existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion)
+                                                       && !string.IsNullOrEmpty(chromecastVersion)
+            ? Enum.Parse<ChromecastVersion>(chromecastVersion, true)
+            : ChromecastVersion.Stable;
+        displayPreferences.CustomPrefs.Remove("chromecastVersion");
+
+        existingDisplayPreferences.EnableNextVideoInfoOverlay = !displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay)
+                                                                || string.IsNullOrEmpty(enableNextVideoInfoOverlay)
+                                                                || bool.Parse(enableNextVideoInfoOverlay);
+        displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay");
+
+        existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength)
+                                                        && !string.IsNullOrEmpty(skipBackLength)
+            ? int.Parse(skipBackLength, CultureInfo.InvariantCulture)
+            : 10000;
+        displayPreferences.CustomPrefs.Remove("skipBackLength");
+
+        existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength)
+                                                       && !string.IsNullOrEmpty(skipForwardLength)
+            ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture)
+            : 30000;
+        displayPreferences.CustomPrefs.Remove("skipForwardLength");
+
+        existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme)
+            ? theme
+            : string.Empty;
+        displayPreferences.CustomPrefs.Remove("dashboardTheme");
+
+        existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home)
+            ? home
+            : string.Empty;
+        displayPreferences.CustomPrefs.Remove("tvhome");
+
+        existingDisplayPreferences.HomeSections.Clear();
+
+        foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase)))
         {
-            HomeSectionType[] defaults =
-            {
-                HomeSectionType.SmallLibraryTiles,
-                HomeSectionType.Resume,
-                HomeSectionType.ResumeAudio,
-                HomeSectionType.ResumeBook,
-                HomeSectionType.LiveTv,
-                HomeSectionType.NextUp,
-                HomeSectionType.LatestMedia,
-                HomeSectionType.None,
-            };
-
-            if (!Guid.TryParse(displayPreferencesId, out var itemId))
+            var order = int.Parse(key.AsSpan().Slice("homesection".Length), CultureInfo.InvariantCulture);
+            if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type))
             {
-                itemId = displayPreferencesId.GetMD5();
+                type = order < 8 ? defaults[order] : HomeSectionType.None;
             }
 
-            var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
-            existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null;
-            existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop;
-            existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar;
-
-            existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection;
-            existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion)
-                                                           && !string.IsNullOrEmpty(chromecastVersion)
-                ? Enum.Parse<ChromecastVersion>(chromecastVersion, true)
-                : ChromecastVersion.Stable;
-            displayPreferences.CustomPrefs.Remove("chromecastVersion");
-
-            existingDisplayPreferences.EnableNextVideoInfoOverlay = !displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay)
-                                                                    || string.IsNullOrEmpty(enableNextVideoInfoOverlay)
-                                                                    || bool.Parse(enableNextVideoInfoOverlay);
-            displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay");
-
-            existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength)
-                                                            && !string.IsNullOrEmpty(skipBackLength)
-                ? int.Parse(skipBackLength, CultureInfo.InvariantCulture)
-                : 10000;
-            displayPreferences.CustomPrefs.Remove("skipBackLength");
-
-            existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength)
-                                                           && !string.IsNullOrEmpty(skipForwardLength)
-                ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture)
-                : 30000;
-            displayPreferences.CustomPrefs.Remove("skipForwardLength");
-
-            existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme)
-                ? theme
-                : string.Empty;
-            displayPreferences.CustomPrefs.Remove("dashboardTheme");
-
-            existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home)
-                ? home
-                : string.Empty;
-            displayPreferences.CustomPrefs.Remove("tvhome");
-
-            existingDisplayPreferences.HomeSections.Clear();
-
-            foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase)))
-            {
-                var order = int.Parse(key.AsSpan().Slice("homesection".Length), CultureInfo.InvariantCulture);
-                if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type))
-                {
-                    type = order < 8 ? defaults[order] : HomeSectionType.None;
-                }
-
-                displayPreferences.CustomPrefs.Remove(key);
-                existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type });
-            }
+            displayPreferences.CustomPrefs.Remove(key);
+            existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type });
+        }
 
-            foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
+        foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
+        {
+            if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type))
             {
-                if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type))
-                {
-                    _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]);
-                    displayPreferences.CustomPrefs.Remove(key);
-                }
+                _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]);
+                displayPreferences.CustomPrefs.Remove(key);
             }
+        }
 
-            var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client);
-            itemPrefs.SortBy = displayPreferences.SortBy ?? "SortName";
-            itemPrefs.SortOrder = displayPreferences.SortOrder;
-            itemPrefs.RememberIndexing = displayPreferences.RememberIndexing;
-            itemPrefs.RememberSorting = displayPreferences.RememberSorting;
-            itemPrefs.ItemId = itemId;
+        var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client);
+        itemPrefs.SortBy = displayPreferences.SortBy ?? "SortName";
+        itemPrefs.SortOrder = displayPreferences.SortOrder;
+        itemPrefs.RememberIndexing = displayPreferences.RememberIndexing;
+        itemPrefs.RememberSorting = displayPreferences.RememberSorting;
+        itemPrefs.ItemId = itemId;
 
-            // Set all remaining custom preferences.
-            _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
-            _displayPreferencesManager.SaveChanges();
+        // Set all remaining custom preferences.
+        _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
+        _displayPreferencesManager.SaveChanges();
 
-            return NoContent();
-        }
+        return NoContent();
     }
 }

+ 103 - 104
Jellyfin.Api/Controllers/DlnaController.cs

@@ -7,127 +7,126 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Dlna Controller.
+/// </summary>
+[Authorize(Policy = Policies.RequiresElevation)]
+public class DlnaController : BaseJellyfinApiController
 {
+    private readonly IDlnaManager _dlnaManager;
+
     /// <summary>
-    /// Dlna Controller.
+    /// Initializes a new instance of the <see cref="DlnaController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.RequiresElevation)]
-    public class DlnaController : BaseJellyfinApiController
+    /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+    public DlnaController(IDlnaManager dlnaManager)
     {
-        private readonly IDlnaManager _dlnaManager;
+        _dlnaManager = dlnaManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DlnaController"/> class.
-        /// </summary>
-        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-        public DlnaController(IDlnaManager dlnaManager)
-        {
-            _dlnaManager = dlnaManager;
-        }
+    /// <summary>
+    /// Get profile infos.
+    /// </summary>
+    /// <response code="200">Device profile infos returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the device profile infos.</returns>
+    [HttpGet("ProfileInfos")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<DeviceProfileInfo>> GetProfileInfos()
+    {
+        return Ok(_dlnaManager.GetProfileInfos());
+    }
 
-        /// <summary>
-        /// Get profile infos.
-        /// </summary>
-        /// <response code="200">Device profile infos returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the device profile infos.</returns>
-        [HttpGet("ProfileInfos")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<DeviceProfileInfo>> GetProfileInfos()
-        {
-            return Ok(_dlnaManager.GetProfileInfos());
-        }
+    /// <summary>
+    /// Gets the default profile.
+    /// </summary>
+    /// <response code="200">Default device profile returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the default profile.</returns>
+    [HttpGet("Profiles/Default")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<DeviceProfile> GetDefaultProfile()
+    {
+        return _dlnaManager.GetDefaultProfile();
+    }
 
-        /// <summary>
-        /// Gets the default profile.
-        /// </summary>
-        /// <response code="200">Default device profile returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the default profile.</returns>
-        [HttpGet("Profiles/Default")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<DeviceProfile> GetDefaultProfile()
+    /// <summary>
+    /// Gets a single profile.
+    /// </summary>
+    /// <param name="profileId">Profile Id.</param>
+    /// <response code="200">Device profile returned.</response>
+    /// <response code="404">Device profile not found.</response>
+    /// <returns>An <see cref="OkResult"/> containing the profile on success, or a <see cref="NotFoundResult"/> if device profile not found.</returns>
+    [HttpGet("Profiles/{profileId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<DeviceProfile> GetProfile([FromRoute, Required] string profileId)
+    {
+        var profile = _dlnaManager.GetProfile(profileId);
+        if (profile is null)
         {
-            return _dlnaManager.GetDefaultProfile();
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets a single profile.
-        /// </summary>
-        /// <param name="profileId">Profile Id.</param>
-        /// <response code="200">Device profile returned.</response>
-        /// <response code="404">Device profile not found.</response>
-        /// <returns>An <see cref="OkResult"/> containing the profile on success, or a <see cref="NotFoundResult"/> if device profile not found.</returns>
-        [HttpGet("Profiles/{profileId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<DeviceProfile> GetProfile([FromRoute, Required] string profileId)
-        {
-            var profile = _dlnaManager.GetProfile(profileId);
-            if (profile is null)
-            {
-                return NotFound();
-            }
+        return profile;
+    }
 
-            return profile;
+    /// <summary>
+    /// Deletes a profile.
+    /// </summary>
+    /// <param name="profileId">Profile id.</param>
+    /// <response code="204">Device profile deleted.</response>
+    /// <response code="404">Device profile not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns>
+    [HttpDelete("Profiles/{profileId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult DeleteProfile([FromRoute, Required] string profileId)
+    {
+        var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
+        if (existingDeviceProfile is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Deletes a profile.
-        /// </summary>
-        /// <param name="profileId">Profile id.</param>
-        /// <response code="204">Device profile deleted.</response>
-        /// <response code="404">Device profile not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns>
-        [HttpDelete("Profiles/{profileId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult DeleteProfile([FromRoute, Required] string profileId)
-        {
-            var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
-            if (existingDeviceProfile is null)
-            {
-                return NotFound();
-            }
+        _dlnaManager.DeleteProfile(profileId);
+        return NoContent();
+    }
 
-            _dlnaManager.DeleteProfile(profileId);
-            return NoContent();
-        }
+    /// <summary>
+    /// Creates a profile.
+    /// </summary>
+    /// <param name="deviceProfile">Device profile.</param>
+    /// <response code="204">Device profile created.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Profiles")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile)
+    {
+        _dlnaManager.CreateProfile(deviceProfile);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Creates a profile.
-        /// </summary>
-        /// <param name="deviceProfile">Device profile.</param>
-        /// <response code="204">Device profile created.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Profiles")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile)
+    /// <summary>
+    /// Updates a profile.
+    /// </summary>
+    /// <param name="profileId">Profile id.</param>
+    /// <param name="deviceProfile">Device profile.</param>
+    /// <response code="204">Device profile updated.</response>
+    /// <response code="404">Device profile not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns>
+    [HttpPost("Profiles/{profileId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult UpdateProfile([FromRoute, Required] string profileId, [FromBody] DeviceProfile deviceProfile)
+    {
+        var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
+        if (existingDeviceProfile is null)
         {
-            _dlnaManager.CreateProfile(deviceProfile);
-            return NoContent();
+            return NotFound();
         }
 
-        /// <summary>
-        /// Updates a profile.
-        /// </summary>
-        /// <param name="profileId">Profile id.</param>
-        /// <param name="deviceProfile">Device profile.</param>
-        /// <response code="204">Device profile updated.</response>
-        /// <response code="404">Device profile not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns>
-        [HttpPost("Profiles/{profileId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult UpdateProfile([FromRoute, Required] string profileId, [FromBody] DeviceProfile deviceProfile)
-        {
-            var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
-            if (existingDeviceProfile is null)
-            {
-                return NotFound();
-            }
-
-            _dlnaManager.UpdateProfile(profileId, deviceProfile);
-            return NoContent();
-        }
+        _dlnaManager.UpdateProfile(profileId, deviceProfile);
+        return NoContent();
     }
 }

+ 275 - 276
Jellyfin.Api/Controllers/DlnaServerController.cs

@@ -14,311 +14,310 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Dlna Server Controller.
+/// </summary>
+[Route("Dlna")]
+[DlnaEnabled]
+[Authorize(Policy = Policies.AnonymousLanAccessPolicy)]
+public class DlnaServerController : BaseJellyfinApiController
 {
+    private readonly IDlnaManager _dlnaManager;
+    private readonly IContentDirectory _contentDirectory;
+    private readonly IConnectionManager _connectionManager;
+    private readonly IMediaReceiverRegistrar _mediaReceiverRegistrar;
+
     /// <summary>
-    /// Dlna Server Controller.
+    /// Initializes a new instance of the <see cref="DlnaServerController"/> class.
     /// </summary>
-    [Route("Dlna")]
-    [DlnaEnabled]
-    [Authorize(Policy = Policies.AnonymousLanAccessPolicy)]
-    public class DlnaServerController : BaseJellyfinApiController
+    /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+    public DlnaServerController(IDlnaManager dlnaManager)
     {
-        private readonly IDlnaManager _dlnaManager;
-        private readonly IContentDirectory _contentDirectory;
-        private readonly IConnectionManager _connectionManager;
-        private readonly IMediaReceiverRegistrar _mediaReceiverRegistrar;
+        _dlnaManager = dlnaManager;
+        _contentDirectory = DlnaEntryPoint.Current.ContentDirectory;
+        _connectionManager = DlnaEntryPoint.Current.ConnectionManager;
+        _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DlnaServerController"/> class.
-        /// </summary>
-        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-        public DlnaServerController(IDlnaManager dlnaManager)
-        {
-            _dlnaManager = dlnaManager;
-            _contentDirectory = DlnaEntryPoint.Current.ContentDirectory;
-            _connectionManager = DlnaEntryPoint.Current.ConnectionManager;
-            _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar;
-        }
+    /// <summary>
+    /// Get Description Xml.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Description xml returned.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>An <see cref="OkResult"/> containing the description xml.</returns>
+    [HttpGet("{serverId}/description")]
+    [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public ActionResult<string> GetDescriptionXml([FromRoute, Required] string serverId)
+    {
+        var url = GetAbsoluteUri();
+        var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase));
+        var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress);
+        return Ok(xml);
+    }
 
-        /// <summary>
-        /// Get Description Xml.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Description xml returned.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>An <see cref="OkResult"/> containing the description xml.</returns>
-        [HttpGet("{serverId}/description")]
-        [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public ActionResult<string> GetDescriptionXml([FromRoute, Required] string serverId)
-        {
-            var url = GetAbsoluteUri();
-            var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase));
-            var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress);
-            return Ok(xml);
-        }
+    /// <summary>
+    /// Gets Dlna content directory xml.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Dlna content directory returned.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns>
+    [HttpGet("{serverId}/ContentDirectory")]
+    [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")]
+    [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    public ActionResult<string> GetContentDirectory([FromRoute, Required] string serverId)
+    {
+        return Ok(_contentDirectory.GetServiceXml());
+    }
 
-        /// <summary>
-        /// Gets Dlna content directory xml.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Dlna content directory returned.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns>
-        [HttpGet("{serverId}/ContentDirectory")]
-        [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")]
-        [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        public ActionResult<string> GetContentDirectory([FromRoute, Required] string serverId)
-        {
-            return Ok(_contentDirectory.GetServiceXml());
-        }
+    /// <summary>
+    /// Gets Dlna media receiver registrar xml.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Dlna media receiver registrar xml returned.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Dlna media receiver registrar xml.</returns>
+    [HttpGet("{serverId}/MediaReceiverRegistrar")]
+    [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
+    [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    public ActionResult<string> GetMediaReceiverRegistrar([FromRoute, Required] string serverId)
+    {
+        return Ok(_mediaReceiverRegistrar.GetServiceXml());
+    }
 
-        /// <summary>
-        /// Gets Dlna media receiver registrar xml.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Dlna media receiver registrar xml returned.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Dlna media receiver registrar xml.</returns>
-        [HttpGet("{serverId}/MediaReceiverRegistrar")]
-        [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
-        [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        public ActionResult<string> GetMediaReceiverRegistrar([FromRoute, Required] string serverId)
-        {
-            return Ok(_mediaReceiverRegistrar.GetServiceXml());
-        }
+    /// <summary>
+    /// Gets Dlna media receiver registrar xml.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Dlna media receiver registrar xml returned.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Dlna media receiver registrar xml.</returns>
+    [HttpGet("{serverId}/ConnectionManager")]
+    [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
+    [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    public ActionResult<string> GetConnectionManager([FromRoute, Required] string serverId)
+    {
+        return Ok(_connectionManager.GetServiceXml());
+    }
 
-        /// <summary>
-        /// Gets Dlna media receiver registrar xml.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Dlna media receiver registrar xml returned.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Dlna media receiver registrar xml.</returns>
-        [HttpGet("{serverId}/ConnectionManager")]
-        [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
-        [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        public ActionResult<string> GetConnectionManager([FromRoute, Required] string serverId)
-        {
-            return Ok(_connectionManager.GetServiceXml());
-        }
+    /// <summary>
+    /// Process a content directory control request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Control response.</returns>
+    [HttpPost("{serverId}/ContentDirectory/Control")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId)
+    {
+        return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Process a content directory control request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Control response.</returns>
-        [HttpPost("{serverId}/ContentDirectory/Control")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId)
-        {
-            return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Process a connection manager control request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Control response.</returns>
+    [HttpPost("{serverId}/ConnectionManager/Control")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId)
+    {
+        return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Process a connection manager control request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Control response.</returns>
-        [HttpPost("{serverId}/ConnectionManager/Control")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId)
-        {
-            return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Process a media receiver registrar control request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Control response.</returns>
+    [HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId)
+    {
+        return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Process a media receiver registrar control request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Control response.</returns>
-        [HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId)
-        {
-            return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Processes an event subscription request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Event subscription response.</returns>
+    [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
+    [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
+    [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
+    {
+        return ProcessEventRequest(_mediaReceiverRegistrar);
+    }
 
-        /// <summary>
-        /// Processes an event subscription request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Event subscription response.</returns>
-        [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
-        [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
-        [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
-        {
-            return ProcessEventRequest(_mediaReceiverRegistrar);
-        }
+    /// <summary>
+    /// Processes an event subscription request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Event subscription response.</returns>
+    [HttpSubscribe("{serverId}/ContentDirectory/Events")]
+    [HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
+    [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
+    {
+        return ProcessEventRequest(_contentDirectory);
+    }
 
-        /// <summary>
-        /// Processes an event subscription request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Event subscription response.</returns>
-        [HttpSubscribe("{serverId}/ContentDirectory/Events")]
-        [HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
-        [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
-        {
-            return ProcessEventRequest(_contentDirectory);
-        }
+    /// <summary>
+    /// Processes an event subscription request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Event subscription response.</returns>
+    [HttpSubscribe("{serverId}/ConnectionManager/Events")]
+    [HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
+    [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
+    {
+        return ProcessEventRequest(_connectionManager);
+    }
 
-        /// <summary>
-        /// Processes an event subscription request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Event subscription response.</returns>
-        [HttpSubscribe("{serverId}/ConnectionManager/Events")]
-        [HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
-        [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
-        {
-            return ProcessEventRequest(_connectionManager);
-        }
+    /// <summary>
+    /// Gets a server icon.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <param name="fileName">The icon filename.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="404">Not Found.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Icon stream.</returns>
+    [HttpGet("{serverId}/icons/{fileName}")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [ProducesImageFile]
+    public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName)
+    {
+        return GetIconInternal(fileName);
+    }
 
-        /// <summary>
-        /// Gets a server icon.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <param name="fileName">The icon filename.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="404">Not Found.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Icon stream.</returns>
-        [HttpGet("{serverId}/icons/{fileName}")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [ProducesImageFile]
-        public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName)
-        {
-            return GetIconInternal(fileName);
-        }
+    /// <summary>
+    /// Gets a server icon.
+    /// </summary>
+    /// <param name="fileName">The icon filename.</param>
+    /// <returns>Icon stream.</returns>
+    /// <response code="200">Request processed.</response>
+    /// <response code="404">Not Found.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    [HttpGet("icons/{fileName}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [ProducesImageFile]
+    public ActionResult GetIcon([FromRoute, Required] string fileName)
+    {
+        return GetIconInternal(fileName);
+    }
 
-        /// <summary>
-        /// Gets a server icon.
-        /// </summary>
-        /// <param name="fileName">The icon filename.</param>
-        /// <returns>Icon stream.</returns>
-        /// <response code="200">Request processed.</response>
-        /// <response code="404">Not Found.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        [HttpGet("icons/{fileName}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [ProducesImageFile]
-        public ActionResult GetIcon([FromRoute, Required] string fileName)
+    private ActionResult GetIconInternal(string fileName)
+    {
+        var icon = _dlnaManager.GetIcon(fileName);
+        if (icon is null)
         {
-            return GetIconInternal(fileName);
+            return NotFound();
         }
 
-        private ActionResult GetIconInternal(string fileName)
-        {
-            var icon = _dlnaManager.GetIcon(fileName);
-            if (icon is null)
-            {
-                return NotFound();
-            }
+        return File(icon.Stream, MimeTypes.GetMimeType(fileName));
+    }
 
-            return File(icon.Stream, MimeTypes.GetMimeType(fileName));
-        }
+    private string GetAbsoluteUri()
+    {
+        return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}";
+    }
 
-        private string GetAbsoluteUri()
+    private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service)
+    {
+        return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers)
         {
-            return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}";
-        }
+            InputXml = requestStream,
+            TargetServerUuId = id,
+            RequestedUrl = GetAbsoluteUri()
+        });
+    }
 
-        private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service)
+    private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager)
+    {
+        var subscriptionId = Request.Headers["SID"];
+        if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase))
         {
-            return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers)
-            {
-                InputXml = requestStream,
-                TargetServerUuId = id,
-                RequestedUrl = GetAbsoluteUri()
-            });
-        }
+            var notificationType = Request.Headers["NT"];
+            var callback = Request.Headers["CALLBACK"];
+            var timeoutString = Request.Headers["TIMEOUT"];
 
-        private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager)
-        {
-            var subscriptionId = Request.Headers["SID"];
-            if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase))
+            if (string.IsNullOrEmpty(notificationType))
             {
-                var notificationType = Request.Headers["NT"];
-                var callback = Request.Headers["CALLBACK"];
-                var timeoutString = Request.Headers["TIMEOUT"];
-
-                if (string.IsNullOrEmpty(notificationType))
-                {
-                    return dlnaEventManager.RenewEventSubscription(
-                        subscriptionId,
-                        notificationType,
-                        timeoutString,
-                        callback);
-                }
-
-                return dlnaEventManager.CreateEventSubscription(notificationType, timeoutString, callback);
+                return dlnaEventManager.RenewEventSubscription(
+                    subscriptionId,
+                    notificationType,
+                    timeoutString,
+                    callback);
             }
 
-            return dlnaEventManager.CancelEventSubscription(subscriptionId);
+            return dlnaEventManager.CreateEventSubscription(notificationType, timeoutString, callback);
         }
+
+        return dlnaEventManager.CancelEventSubscription(subscriptionId);
     }
 }

+ 1829 - 1830
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -30,2026 +30,2025 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Dynamic hls controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class DynamicHlsController : BaseJellyfinApiController
 {
+    private const string DefaultVodEncoderPreset = "veryfast";
+    private const string DefaultEventEncoderPreset = "superfast";
+    private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
+
+    private readonly ILibraryManager _libraryManager;
+    private readonly IUserManager _userManager;
+    private readonly IDlnaManager _dlnaManager;
+    private readonly IMediaSourceManager _mediaSourceManager;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly IMediaEncoder _mediaEncoder;
+    private readonly IFileSystem _fileSystem;
+    private readonly IDeviceManager _deviceManager;
+    private readonly TranscodingJobHelper _transcodingJobHelper;
+    private readonly ILogger<DynamicHlsController> _logger;
+    private readonly EncodingHelper _encodingHelper;
+    private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
+    private readonly DynamicHlsHelper _dynamicHlsHelper;
+    private readonly EncodingOptions _encodingOptions;
+
     /// <summary>
-    /// Dynamic hls controller.
+    /// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class DynamicHlsController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+    /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param>
+    /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
+    /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
+    /// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param>
+    public DynamicHlsController(
+        ILibraryManager libraryManager,
+        IUserManager userManager,
+        IDlnaManager dlnaManager,
+        IMediaSourceManager mediaSourceManager,
+        IServerConfigurationManager serverConfigurationManager,
+        IMediaEncoder mediaEncoder,
+        IFileSystem fileSystem,
+        IDeviceManager deviceManager,
+        TranscodingJobHelper transcodingJobHelper,
+        ILogger<DynamicHlsController> logger,
+        DynamicHlsHelper dynamicHlsHelper,
+        EncodingHelper encodingHelper,
+        IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
     {
-        private const string DefaultVodEncoderPreset = "veryfast";
-        private const string DefaultEventEncoderPreset = "superfast";
-        private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
-
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-        private readonly IDlnaManager _dlnaManager;
-        private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly IMediaEncoder _mediaEncoder;
-        private readonly IFileSystem _fileSystem;
-        private readonly IDeviceManager _deviceManager;
-        private readonly TranscodingJobHelper _transcodingJobHelper;
-        private readonly ILogger<DynamicHlsController> _logger;
-        private readonly EncodingHelper _encodingHelper;
-        private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
-        private readonly DynamicHlsHelper _dynamicHlsHelper;
-        private readonly EncodingOptions _encodingOptions;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
-        /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param>
-        /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
-        /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
-        /// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param>
-        public DynamicHlsController(
-            ILibraryManager libraryManager,
-            IUserManager userManager,
-            IDlnaManager dlnaManager,
-            IMediaSourceManager mediaSourceManager,
-            IServerConfigurationManager serverConfigurationManager,
-            IMediaEncoder mediaEncoder,
-            IFileSystem fileSystem,
-            IDeviceManager deviceManager,
-            TranscodingJobHelper transcodingJobHelper,
-            ILogger<DynamicHlsController> logger,
-            DynamicHlsHelper dynamicHlsHelper,
-            EncodingHelper encodingHelper,
-            IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
-        {
-            _libraryManager = libraryManager;
-            _userManager = userManager;
-            _dlnaManager = dlnaManager;
-            _mediaSourceManager = mediaSourceManager;
-            _serverConfigurationManager = serverConfigurationManager;
-            _mediaEncoder = mediaEncoder;
-            _fileSystem = fileSystem;
-            _deviceManager = deviceManager;
-            _transcodingJobHelper = transcodingJobHelper;
-            _logger = logger;
-            _dynamicHlsHelper = dynamicHlsHelper;
-            _encodingHelper = encodingHelper;
-            _dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
-
-            _encodingOptions = serverConfigurationManager.GetEncodingOptions();
-        }
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+        _dlnaManager = dlnaManager;
+        _mediaSourceManager = mediaSourceManager;
+        _serverConfigurationManager = serverConfigurationManager;
+        _mediaEncoder = mediaEncoder;
+        _fileSystem = fileSystem;
+        _deviceManager = deviceManager;
+        _transcodingJobHelper = transcodingJobHelper;
+        _logger = logger;
+        _dynamicHlsHelper = dynamicHlsHelper;
+        _encodingHelper = encodingHelper;
+        _dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
+
+        _encodingOptions = serverConfigurationManager.GetEncodingOptions();
+    }
 
-        /// <summary>
-        /// Gets a hls live stream.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="container">The audio container.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <param name="maxWidth">Optional. The max width.</param>
-        /// <param name="maxHeight">Optional. The max height.</param>
-        /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param>
-        /// <response code="200">Hls live stream retrieved.</response>
-        /// <returns>A <see cref="FileResult"/> containing the hls file.</returns>
-        [HttpGet("Videos/{itemId}/live.m3u8")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        public async Task<ActionResult> GetLiveHlsStream(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] string? container,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] bool? enableSubtitlesInManifest)
+    /// <summary>
+    /// Gets a hls live stream.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="container">The audio container.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <param name="maxWidth">Optional. The max width.</param>
+    /// <param name="maxHeight">Optional. The max height.</param>
+    /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param>
+    /// <response code="200">Hls live stream retrieved.</response>
+    /// <returns>A <see cref="FileResult"/> containing the hls file.</returns>
+    [HttpGet("Videos/{itemId}/live.m3u8")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetLiveHlsStream(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] string? container,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] bool? enableSubtitlesInManifest)
+    {
+        VideoRequestDto streamingRequest = new VideoRequestDto
         {
-            VideoRequestDto streamingRequest = new VideoRequestDto
-            {
-                Id = itemId,
-                Container = container,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions,
-                MaxHeight = maxHeight,
-                MaxWidth = maxWidth,
-                EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true
-            };
-
-            // CTS lifecycle is managed internally.
-            var cancellationTokenSource = new CancellationTokenSource();
-            // Due to CTS.Token calling ThrowIfDisposed (https://github.com/dotnet/runtime/issues/29970) we have to "cache" the token
-            // since it gets disposed when ffmpeg exits
-            var cancellationToken = cancellationTokenSource.Token;
-            var state = await StreamingHelpers.GetStreamingState(
-                    streamingRequest,
-                    HttpContext,
-                    _mediaSourceManager,
-                    _userManager,
-                    _libraryManager,
-                    _serverConfigurationManager,
-                    _mediaEncoder,
-                    _encodingHelper,
-                    _dlnaManager,
-                    _deviceManager,
-                    _transcodingJobHelper,
-                    TranscodingJobType,
-                    cancellationToken)
-                .ConfigureAwait(false);
-
-            TranscodingJobDto? job = null;
-            var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
-
-            if (!System.IO.File.Exists(playlistPath))
+            Id = itemId,
+            Container = container,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions,
+            MaxHeight = maxHeight,
+            MaxWidth = maxWidth,
+            EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true
+        };
+
+        // CTS lifecycle is managed internally.
+        var cancellationTokenSource = new CancellationTokenSource();
+        // Due to CTS.Token calling ThrowIfDisposed (https://github.com/dotnet/runtime/issues/29970) we have to "cache" the token
+        // since it gets disposed when ffmpeg exits
+        var cancellationToken = cancellationTokenSource.Token;
+        var state = await StreamingHelpers.GetStreamingState(
+                streamingRequest,
+                HttpContext,
+                _mediaSourceManager,
+                _userManager,
+                _libraryManager,
+                _serverConfigurationManager,
+                _mediaEncoder,
+                _encodingHelper,
+                _dlnaManager,
+                _deviceManager,
+                _transcodingJobHelper,
+                TranscodingJobType,
+                cancellationToken)
+            .ConfigureAwait(false);
+
+        TranscodingJobDto? job = null;
+        var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
+
+        if (!System.IO.File.Exists(playlistPath))
+        {
+            var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
+            await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+            try
             {
-                var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
-                await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false);
-                try
+                if (!System.IO.File.Exists(playlistPath))
                 {
-                    if (!System.IO.File.Exists(playlistPath))
+                    // If the playlist doesn't already exist, startup ffmpeg
+                    try
                     {
-                        // If the playlist doesn't already exist, startup ffmpeg
-                        try
-                        {
-                            job = await _transcodingJobHelper.StartFfMpeg(
-                                    state,
-                                    playlistPath,
-                                    GetCommandLineArguments(playlistPath, state, true, 0),
-                                    Request,
-                                    TranscodingJobType,
-                                    cancellationTokenSource)
-                                .ConfigureAwait(false);
-                            job.IsLiveOutput = true;
-                        }
-                        catch
-                        {
-                            state.Dispose();
-                            throw;
-                        }
+                        job = await _transcodingJobHelper.StartFfMpeg(
+                                state,
+                                playlistPath,
+                                GetCommandLineArguments(playlistPath, state, true, 0),
+                                Request,
+                                TranscodingJobType,
+                                cancellationTokenSource)
+                            .ConfigureAwait(false);
+                        job.IsLiveOutput = true;
+                    }
+                    catch
+                    {
+                        state.Dispose();
+                        throw;
+                    }
 
-                        minSegments = state.MinSegments;
-                        if (minSegments > 0)
-                        {
-                            await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationToken).ConfigureAwait(false);
-                        }
+                    minSegments = state.MinSegments;
+                    if (minSegments > 0)
+                    {
+                        await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationToken).ConfigureAwait(false);
                     }
                 }
-                finally
-                {
-                    transcodingLock.Release();
-                }
             }
-
-            job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
-
-            if (job is not null)
+            finally
             {
-                _transcodingJobHelper.OnTranscodeEndRequest(job);
+                transcodingLock.Release();
             }
-
-            var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state);
-
-            return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8"));
         }
 
-        /// <summary>
-        /// Gets a video hls playlist stream.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
-        /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
-        /// <response code="200">Video stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
-        [HttpGet("Videos/{itemId}/master.m3u8")]
-        [HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadMasterHlsVideoPlaylist")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        public async Task<ActionResult> GetMasterHlsVideoPlaylist(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery, Required] string mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions,
-            [FromQuery] bool enableAdaptiveBitrateStreaming = true)
-        {
-            var streamingRequest = new HlsVideoRequestDto
-            {
-                Id = itemId,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                MaxWidth = maxWidth,
-                MaxHeight = maxHeight,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions,
-                EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
-            };
+        job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
 
-            return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+        if (job is not null)
+        {
+            _transcodingJobHelper.OnTranscodeEndRequest(job);
         }
 
-        /// <summary>
-        /// Gets an audio hls playlist stream.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
-        /// <response code="200">Audio stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
-        [HttpGet("Audio/{itemId}/master.m3u8")]
-        [HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadMasterHlsAudioPlaylist")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        public async Task<ActionResult> GetMasterHlsAudioPlaylist(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery, Required] string mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? maxStreamingBitrate,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions,
-            [FromQuery] bool enableAdaptiveBitrateStreaming = true)
-        {
-            var streamingRequest = new HlsAudioRequestDto
-            {
-                Id = itemId,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions,
-                EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
-            };
+        var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state);
 
-            return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
-        }
+        return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8"));
+    }
 
-        /// <summary>
-        /// Gets a video stream using HTTP live streaming.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
-        /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Video stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("Videos/{itemId}/main.m3u8")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        public async Task<ActionResult> GetVariantHlsVideoPlaylist(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions)
+    /// <summary>
+    /// Gets a video hls playlist stream.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
+    /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
+    /// <response code="200">Video stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
+    [HttpGet("Videos/{itemId}/master.m3u8")]
+    [HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadMasterHlsVideoPlaylist")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetMasterHlsVideoPlaylist(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery, Required] string mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions,
+        [FromQuery] bool enableAdaptiveBitrateStreaming = true)
+    {
+        var streamingRequest = new HlsVideoRequestDto
         {
-            using var cancellationTokenSource = new CancellationTokenSource();
-            var streamingRequest = new VideoRequestDto
-            {
-                Id = itemId,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                MaxWidth = maxWidth,
-                MaxHeight = maxHeight,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions
-            };
-
-            return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
-                .ConfigureAwait(false);
-        }
+            Id = itemId,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            MaxWidth = maxWidth,
+            MaxHeight = maxHeight,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions,
+            EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
+        };
+
+        return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Gets an audio stream using HTTP live streaming.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Audio stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("Audio/{itemId}/main.m3u8")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        public async Task<ActionResult> GetVariantHlsAudioPlaylist(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? maxStreamingBitrate,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions)
+    /// <summary>
+    /// Gets an audio hls playlist stream.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
+    /// <response code="200">Audio stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
+    [HttpGet("Audio/{itemId}/master.m3u8")]
+    [HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadMasterHlsAudioPlaylist")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetMasterHlsAudioPlaylist(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery, Required] string mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? maxStreamingBitrate,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions,
+        [FromQuery] bool enableAdaptiveBitrateStreaming = true)
+    {
+        var streamingRequest = new HlsAudioRequestDto
         {
-            using var cancellationTokenSource = new CancellationTokenSource();
-            var streamingRequest = new StreamingRequestDto
-            {
-                Id = itemId,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions
-            };
+            Id = itemId,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate ?? maxStreamingBitrate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions,
+            EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
+        };
+
+        return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+    }
 
-            return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
-                .ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Gets a video stream using HTTP live streaming.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
+    /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Video stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("Videos/{itemId}/main.m3u8")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetVariantHlsVideoPlaylist(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions)
+    {
+        using var cancellationTokenSource = new CancellationTokenSource();
+        var streamingRequest = new VideoRequestDto
+        {
+            Id = itemId,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            MaxWidth = maxWidth,
+            MaxHeight = maxHeight,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions
+        };
+
+        return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
+            .ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Gets a video stream using HTTP live streaming.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <param name="segmentId">The segment id.</param>
-        /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
-        /// <param name="runtimeTicks">The position of the requested segment in ticks.</param>
-        /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The desired segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
-        /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Video stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesVideoFile]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> GetHlsVideoSegment(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] string playlistId,
-            [FromRoute, Required] int segmentId,
-            [FromRoute, Required] string container,
-            [FromQuery, Required] long runtimeTicks,
-            [FromQuery, Required] long actualSegmentLengthTicks,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions)
+    /// <summary>
+    /// Gets an audio stream using HTTP live streaming.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Audio stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("Audio/{itemId}/main.m3u8")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetVariantHlsAudioPlaylist(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? maxStreamingBitrate,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions)
+    {
+        using var cancellationTokenSource = new CancellationTokenSource();
+        var streamingRequest = new StreamingRequestDto
         {
-            var streamingRequest = new VideoRequestDto
-            {
-                Id = itemId,
-                CurrentRuntimeTicks = runtimeTicks,
-                ActualSegmentLengthTicks = actualSegmentLengthTicks,
-                Container = container,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                MaxWidth = maxWidth,
-                MaxHeight = maxHeight,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions
-            };
+            Id = itemId,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate ?? maxStreamingBitrate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions
+        };
+
+        return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
+            .ConfigureAwait(false);
+    }
 
-            return await GetDynamicSegment(streamingRequest, segmentId)
-                .ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Gets a video stream using HTTP live streaming.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="segmentId">The segment id.</param>
+    /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
+    /// <param name="runtimeTicks">The position of the requested segment in ticks.</param>
+    /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The desired segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
+    /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Video stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesVideoFile]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> GetHlsVideoSegment(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] string playlistId,
+        [FromRoute, Required] int segmentId,
+        [FromRoute, Required] string container,
+        [FromQuery, Required] long runtimeTicks,
+        [FromQuery, Required] long actualSegmentLengthTicks,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions)
+    {
+        var streamingRequest = new VideoRequestDto
+        {
+            Id = itemId,
+            CurrentRuntimeTicks = runtimeTicks,
+            ActualSegmentLengthTicks = actualSegmentLengthTicks,
+            Container = container,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            MaxWidth = maxWidth,
+            MaxHeight = maxHeight,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions
+        };
+
+        return await GetDynamicSegment(streamingRequest, segmentId)
+            .ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Gets a video stream using HTTP live streaming.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <param name="segmentId">The segment id.</param>
-        /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
-        /// <param name="runtimeTicks">The position of the requested segment in ticks.</param>
-        /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Video stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesAudioFile]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> GetHlsAudioSegment(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] string playlistId,
-            [FromRoute, Required] int segmentId,
-            [FromRoute, Required] string container,
-            [FromQuery, Required] long runtimeTicks,
-            [FromQuery, Required] long actualSegmentLengthTicks,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? maxStreamingBitrate,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions)
+    /// <summary>
+    /// Gets a video stream using HTTP live streaming.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="segmentId">The segment id.</param>
+    /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
+    /// <param name="runtimeTicks">The position of the requested segment in ticks.</param>
+    /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Video stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesAudioFile]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> GetHlsAudioSegment(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] string playlistId,
+        [FromRoute, Required] int segmentId,
+        [FromRoute, Required] string container,
+        [FromQuery, Required] long runtimeTicks,
+        [FromQuery, Required] long actualSegmentLengthTicks,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? maxStreamingBitrate,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions)
+    {
+        var streamingRequest = new StreamingRequestDto
         {
-            var streamingRequest = new StreamingRequestDto
-            {
-                Id = itemId,
-                Container = container,
-                CurrentRuntimeTicks = runtimeTicks,
-                ActualSegmentLengthTicks = actualSegmentLengthTicks,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions
-            };
+            Id = itemId,
+            Container = container,
+            CurrentRuntimeTicks = runtimeTicks,
+            ActualSegmentLengthTicks = actualSegmentLengthTicks,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate ?? maxStreamingBitrate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions
+        };
+
+        return await GetDynamicSegment(streamingRequest, segmentId)
+            .ConfigureAwait(false);
+    }
 
-            return await GetDynamicSegment(streamingRequest, segmentId)
-                .ConfigureAwait(false);
-        }
+    private async Task<ActionResult> GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, CancellationTokenSource cancellationTokenSource)
+    {
+        using var state = await StreamingHelpers.GetStreamingState(
+                streamingRequest,
+                HttpContext,
+                _mediaSourceManager,
+                _userManager,
+                _libraryManager,
+                _serverConfigurationManager,
+                _mediaEncoder,
+                _encodingHelper,
+                _dlnaManager,
+                _deviceManager,
+                _transcodingJobHelper,
+                TranscodingJobType,
+                cancellationTokenSource.Token)
+            .ConfigureAwait(false);
+
+        var request = new CreateMainPlaylistRequest(
+            state.MediaPath,
+            state.SegmentLength * 1000,
+            state.RunTimeTicks ?? 0,
+            state.Request.SegmentContainer ?? string.Empty,
+            "hls1/main/",
+            Request.QueryString.ToString(),
+            EncodingHelper.IsCopyCodec(state.OutputVideoCodec));
+        var playlist = _dynamicHlsPlaylistGenerator.CreateMainPlaylist(request);
+
+        return new FileContentResult(Encoding.UTF8.GetBytes(playlist), MimeTypes.GetMimeType("playlist.m3u8"));
+    }
 
-        private async Task<ActionResult> GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, CancellationTokenSource cancellationTokenSource)
+    private async Task<ActionResult> GetDynamicSegment(StreamingRequestDto streamingRequest, int segmentId)
+    {
+        if ((streamingRequest.StartTimeTicks ?? 0) > 0)
         {
-            using var state = await StreamingHelpers.GetStreamingState(
-                    streamingRequest,
-                    HttpContext,
-                    _mediaSourceManager,
-                    _userManager,
-                    _libraryManager,
-                    _serverConfigurationManager,
-                    _mediaEncoder,
-                    _encodingHelper,
-                    _dlnaManager,
-                    _deviceManager,
-                    _transcodingJobHelper,
-                    TranscodingJobType,
-                    cancellationTokenSource.Token)
-                .ConfigureAwait(false);
-
-            var request = new CreateMainPlaylistRequest(
-                state.MediaPath,
-                state.SegmentLength * 1000,
-                state.RunTimeTicks ?? 0,
-                state.Request.SegmentContainer ?? string.Empty,
-                "hls1/main/",
-                Request.QueryString.ToString(),
-                EncodingHelper.IsCopyCodec(state.OutputVideoCodec));
-            var playlist = _dynamicHlsPlaylistGenerator.CreateMainPlaylist(request);
-
-            return new FileContentResult(Encoding.UTF8.GetBytes(playlist), MimeTypes.GetMimeType("playlist.m3u8"));
+            throw new ArgumentException("StartTimeTicks is not allowed.");
         }
 
-        private async Task<ActionResult> GetDynamicSegment(StreamingRequestDto streamingRequest, int segmentId)
-        {
-            if ((streamingRequest.StartTimeTicks ?? 0) > 0)
-            {
-                throw new ArgumentException("StartTimeTicks is not allowed.");
-            }
+        // CTS lifecycle is managed internally.
+        var cancellationTokenSource = new CancellationTokenSource();
+        var cancellationToken = cancellationTokenSource.Token;
 
-            // CTS lifecycle is managed internally.
-            var cancellationTokenSource = new CancellationTokenSource();
-            var cancellationToken = cancellationTokenSource.Token;
+        var state = await StreamingHelpers.GetStreamingState(
+                streamingRequest,
+                HttpContext,
+                _mediaSourceManager,
+                _userManager,
+                _libraryManager,
+                _serverConfigurationManager,
+                _mediaEncoder,
+                _encodingHelper,
+                _dlnaManager,
+                _deviceManager,
+                _transcodingJobHelper,
+                TranscodingJobType,
+                cancellationToken)
+            .ConfigureAwait(false);
 
-            var state = await StreamingHelpers.GetStreamingState(
-                    streamingRequest,
-                    HttpContext,
-                    _mediaSourceManager,
-                    _userManager,
-                    _libraryManager,
-                    _serverConfigurationManager,
-                    _mediaEncoder,
-                    _encodingHelper,
-                    _dlnaManager,
-                    _deviceManager,
-                    _transcodingJobHelper,
-                    TranscodingJobType,
-                    cancellationToken)
-                .ConfigureAwait(false);
+        var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
 
-            var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
+        var segmentPath = GetSegmentPath(state, playlistPath, segmentId);
 
-            var segmentPath = GetSegmentPath(state, playlistPath, segmentId);
+        var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
 
-            var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
+        TranscodingJobDto? job;
+
+        if (System.IO.File.Exists(segmentPath))
+        {
+            job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
+            _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath);
+            return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
+        }
 
-            TranscodingJobDto? job;
+        var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
+        await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+        var released = false;
+        var startTranscoding = false;
 
+        try
+        {
             if (System.IO.File.Exists(segmentPath))
             {
                 job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
-                _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath);
+                transcodingLock.Release();
+                released = true;
+                _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath);
                 return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
             }
-
-            var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
-            await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false);
-            var released = false;
-            var startTranscoding = false;
-
-            try
+            else
             {
-                if (System.IO.File.Exists(segmentPath))
+                var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
+                var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength;
+
+                if (segmentId == -1)
                 {
-                    job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
-                    transcodingLock.Release();
-                    released = true;
-                    _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath);
-                    return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
+                    _logger.LogDebug("Starting transcoding because fmp4 init file is being requested");
+                    startTranscoding = true;
+                    segmentId = 0;
                 }
-                else
+                else if (currentTranscodingIndex is null)
                 {
-                    var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
-                    var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength;
-
-                    if (segmentId == -1)
-                    {
-                        _logger.LogDebug("Starting transcoding because fmp4 init file is being requested");
-                        startTranscoding = true;
-                        segmentId = 0;
-                    }
-                    else if (currentTranscodingIndex is null)
-                    {
-                        _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null");
-                        startTranscoding = true;
-                    }
-                    else if (segmentId < currentTranscodingIndex.Value)
-                    {
-                        _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex);
-                        startTranscoding = true;
-                    }
-                    else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange)
-                    {
-                        _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId);
-                        startTranscoding = true;
-                    }
+                    _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null");
+                    startTranscoding = true;
+                }
+                else if (segmentId < currentTranscodingIndex.Value)
+                {
+                    _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex);
+                    startTranscoding = true;
+                }
+                else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange)
+                {
+                    _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId);
+                    startTranscoding = true;
+                }
 
-                    if (startTranscoding)
+                if (startTranscoding)
+                {
+                    // If the playlist doesn't already exist, startup ffmpeg
+                    try
                     {
-                        // If the playlist doesn't already exist, startup ffmpeg
-                        try
-                        {
-                            await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false)
-                                .ConfigureAwait(false);
+                        await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false)
+                            .ConfigureAwait(false);
 
-                            if (currentTranscodingIndex.HasValue)
-                            {
-                                DeleteLastFile(playlistPath, segmentExtension, 0);
-                            }
-
-                            streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
-
-                            state.WaitForPath = segmentPath;
-                            job = await _transcodingJobHelper.StartFfMpeg(
-                                state,
-                                playlistPath,
-                                GetCommandLineArguments(playlistPath, state, false, segmentId),
-                                Request,
-                                TranscodingJobType,
-                                cancellationTokenSource).ConfigureAwait(false);
-                        }
-                        catch
+                        if (currentTranscodingIndex.HasValue)
                         {
-                            state.Dispose();
-                            throw;
+                            DeleteLastFile(playlistPath, segmentExtension, 0);
                         }
 
-                        // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false);
+                        streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
+
+                        state.WaitForPath = segmentPath;
+                        job = await _transcodingJobHelper.StartFfMpeg(
+                            state,
+                            playlistPath,
+                            GetCommandLineArguments(playlistPath, state, false, segmentId),
+                            Request,
+                            TranscodingJobType,
+                            cancellationTokenSource).ConfigureAwait(false);
                     }
-                    else
+                    catch
                     {
-                        job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
-                        if (job?.TranscodingThrottler is not null)
-                        {
-                            await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
-                        }
+                        state.Dispose();
+                        throw;
                     }
+
+                    // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false);
                 }
-            }
-            finally
-            {
-                if (!released)
+                else
                 {
-                    transcodingLock.Release();
+                    job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
+                    if (job?.TranscodingThrottler is not null)
+                    {
+                        await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
+                    }
                 }
             }
-
-            _logger.LogDebug("returning {0} [general case]", segmentPath);
-            job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
-            return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
         }
-
-        private static double[] GetSegmentLengths(StreamState state)
-            => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
-
-        internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength)
+        finally
         {
-            var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
-            var wholeSegments = runtimeTicks / segmentLengthTicks;
-            var remainingTicks = runtimeTicks % segmentLengthTicks;
-
-            var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1);
-            var segments = new double[segmentsLen];
-            for (int i = 0; i < wholeSegments; i++)
+            if (!released)
             {
-                segments[i] = segmentlength;
+                transcodingLock.Release();
             }
+        }
 
-            if (remainingTicks != 0)
-            {
-                segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds;
-            }
+        _logger.LogDebug("returning {0} [general case]", segmentPath);
+        job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
+        return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
+    }
+
+    private static double[] GetSegmentLengths(StreamState state)
+        => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
 
-            return segments;
+    internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength)
+    {
+        var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
+        var wholeSegments = runtimeTicks / segmentLengthTicks;
+        var remainingTicks = runtimeTicks % segmentLengthTicks;
+
+        var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1);
+        var segments = new double[segmentsLen];
+        for (int i = 0; i < wholeSegments; i++)
+        {
+            segments[i] = segmentlength;
         }
 
-        private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
+        if (remainingTicks != 0)
         {
-            var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
-            var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
+            segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds;
+        }
 
-            if (state.BaseRequest.BreakOnNonKeyFrames)
-            {
-                // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe
-                //        breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable
-                //        to produce a missing part of video stream before first keyframe is encountered, which may lead to
-                //        awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js
-                _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request");
-                state.BaseRequest.BreakOnNonKeyFrames = false;
-            }
+        return segments;
+    }
 
-            var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
+    private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
+    {
+        var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+        var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
 
-            var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
-            var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
-            var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
-            var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
-            var outputTsArg = outputPrefix + "%d" + outputExtension;
+        if (state.BaseRequest.BreakOnNonKeyFrames)
+        {
+            // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe
+            //        breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable
+            //        to produce a missing part of video stream before first keyframe is encountered, which may lead to
+            //        awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js
+            _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request");
+            state.BaseRequest.BreakOnNonKeyFrames = false;
+        }
 
-            var segmentFormat = string.Empty;
-            var segmentContainer = outputExtension.TrimStart('.');
-            var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
+        var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
 
-            if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
-            {
-                segmentFormat = "mpegts";
-            }
-            else if (string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
-            {
-                var outputFmp4HeaderArg = OperatingSystem.IsWindows() switch
-                {
-                    // on Windows, the path of fmp4 header file needs to be configured
-                    true => " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"",
-                    // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
-                    false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""
-                };
+        var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
+        var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+        var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
+        var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
+        var outputTsArg = outputPrefix + "%d" + outputExtension;
 
-                segmentFormat = "fmp4" + outputFmp4HeaderArg;
-            }
-            else
+        var segmentFormat = string.Empty;
+        var segmentContainer = outputExtension.TrimStart('.');
+        var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
+
+        if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
+        {
+            segmentFormat = "mpegts";
+        }
+        else if (string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
+        {
+            var outputFmp4HeaderArg = OperatingSystem.IsWindows() switch
             {
-                _logger.LogError("Invalid HLS segment container: {SegmentContainer}, default to mpegts", segmentContainer);
-                segmentFormat = "mpegts";
-            }
+                // on Windows, the path of fmp4 header file needs to be configured
+                true => " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"",
+                // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
+                false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""
+            };
 
-            var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
-                ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
-                : "128";
+            segmentFormat = "fmp4" + outputFmp4HeaderArg;
+        }
+        else
+        {
+            _logger.LogError("Invalid HLS segment container: {SegmentContainer}, default to mpegts", segmentContainer);
+            segmentFormat = "mpegts";
+        }
 
-            var baseUrlParam = string.Empty;
-            if (isEventPlaylist)
-            {
-                baseUrlParam = string.Format(
-                    CultureInfo.InvariantCulture,
-                    " -hls_base_url \"hls/{0}/\"",
-                    Path.GetFileNameWithoutExtension(outputPath));
-            }
+        var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
+            ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
+            : "128";
 
-            return string.Format(
+        var baseUrlParam = string.Empty;
+        if (isEventPlaylist)
+        {
+            baseUrlParam = string.Format(
                 CultureInfo.InvariantCulture,
-                "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{12}\" -hls_playlist_type {11} -hls_list_size 0 -y \"{13}\"",
-                inputModifier,
-                _encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
-                threads,
-                mapArgs,
-                GetVideoArguments(state, startNumber, isEventPlaylist),
-                GetAudioArguments(state),
-                maxMuxingQueueSize,
-                state.SegmentLength.ToString(CultureInfo.InvariantCulture),
-                segmentFormat,
-                startNumber.ToString(CultureInfo.InvariantCulture),
-                baseUrlParam,
-                isEventPlaylist ? "event" : "vod",
-                outputTsArg,
-                outputPath).Trim();
+                " -hls_base_url \"hls/{0}/\"",
+                Path.GetFileNameWithoutExtension(outputPath));
         }
 
-        /// <summary>
-        /// Gets the audio arguments for transcoding.
-        /// </summary>
-        /// <param name="state">The <see cref="StreamState"/>.</param>
-        /// <returns>The command line arguments for audio transcoding.</returns>
-        private string GetAudioArguments(StreamState state)
+        return string.Format(
+            CultureInfo.InvariantCulture,
+            "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{12}\" -hls_playlist_type {11} -hls_list_size 0 -y \"{13}\"",
+            inputModifier,
+            _encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
+            threads,
+            mapArgs,
+            GetVideoArguments(state, startNumber, isEventPlaylist),
+            GetAudioArguments(state),
+            maxMuxingQueueSize,
+            state.SegmentLength.ToString(CultureInfo.InvariantCulture),
+            segmentFormat,
+            startNumber.ToString(CultureInfo.InvariantCulture),
+            baseUrlParam,
+            isEventPlaylist ? "event" : "vod",
+            outputTsArg,
+            outputPath).Trim();
+    }
+
+    /// <summary>
+    /// Gets the audio arguments for transcoding.
+    /// </summary>
+    /// <param name="state">The <see cref="StreamState"/>.</param>
+    /// <returns>The command line arguments for audio transcoding.</returns>
+    private string GetAudioArguments(StreamState state)
+    {
+        if (state.AudioStream is null)
         {
-            if (state.AudioStream is null)
-            {
-                return string.Empty;
-            }
+            return string.Empty;
+        }
 
-            var audioCodec = _encodingHelper.GetAudioEncoder(state);
+        var audioCodec = _encodingHelper.GetAudioEncoder(state);
 
-            if (!state.IsOutputVideo)
+        if (!state.IsOutputVideo)
+        {
+            if (EncodingHelper.IsCopyCodec(audioCodec))
             {
-                if (EncodingHelper.IsCopyCodec(audioCodec))
-                {
-                    var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
-
-                    return "-acodec copy -strict -2" + bitStreamArgs;
-                }
-
-                var audioTranscodeParams = string.Empty;
-
-                audioTranscodeParams += "-acodec " + audioCodec;
+                var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
 
-                if (state.OutputAudioBitrate.HasValue)
-                {
-                    audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture);
-                }
+                return "-acodec copy -strict -2" + bitStreamArgs;
+            }
 
-                if (state.OutputAudioChannels.HasValue)
-                {
-                    audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture);
-                }
+            var audioTranscodeParams = string.Empty;
 
-                if (state.OutputAudioSampleRate.HasValue)
-                {
-                    audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
-                }
+            audioTranscodeParams += "-acodec " + audioCodec;
 
-                audioTranscodeParams += " -vn";
-                return audioTranscodeParams;
+            if (state.OutputAudioBitrate.HasValue)
+            {
+                audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture);
             }
 
-            // dts, flac, opus and truehd are experimental in mp4 muxer
-            var strictArgs = string.Empty;
-
-            if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase))
+            if (state.OutputAudioChannels.HasValue)
             {
-                strictArgs = " -strict -2";
+                audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture);
             }
 
-            if (EncodingHelper.IsCopyCodec(audioCodec))
+            if (state.OutputAudioSampleRate.HasValue)
             {
-                var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
-                var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
-                var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs;
+                audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
+            }
 
-                if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
-                {
-                    return copyArgs + " -copypriorss:a:0 0";
-                }
+            audioTranscodeParams += " -vn";
+            return audioTranscodeParams;
+        }
 
-                return copyArgs;
-            }
+        // dts, flac, opus and truehd are experimental in mp4 muxer
+        var strictArgs = string.Empty;
 
-            var args = "-codec:a:0 " + audioCodec + strictArgs;
+        if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase))
+        {
+            strictArgs = " -strict -2";
+        }
 
-            var channels = state.OutputAudioChannels;
+        if (EncodingHelper.IsCopyCodec(audioCodec))
+        {
+            var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+            var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
+            var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs;
 
-            if (channels.HasValue
-                && (channels.Value != 2
-                    || (state.AudioStream is not null
-                        && state.AudioStream.Channels.HasValue
-                        && state.AudioStream.Channels.Value > 5
-                        && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None)))
+            if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
             {
-                args += " -ac " + channels.Value;
+                return copyArgs + " -copypriorss:a:0 0";
             }
 
-            var bitrate = state.OutputAudioBitrate;
+            return copyArgs;
+        }
 
-            if (bitrate.HasValue)
-            {
-                args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture);
-            }
+        var args = "-codec:a:0 " + audioCodec + strictArgs;
 
-            if (state.OutputAudioSampleRate.HasValue)
-            {
-                args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
-            }
+        var channels = state.OutputAudioChannels;
+
+        if (channels.HasValue
+            && (channels.Value != 2
+                || (state.AudioStream is not null
+                    && state.AudioStream.Channels.HasValue
+                    && state.AudioStream.Channels.Value > 5
+                    && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None)))
+        {
+            args += " -ac " + channels.Value;
+        }
 
-            args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions);
+        var bitrate = state.OutputAudioBitrate;
 
-            return args;
+        if (bitrate.HasValue)
+        {
+            args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture);
         }
 
-        /// <summary>
-        /// Gets the video arguments for transcoding.
-        /// </summary>
-        /// <param name="state">The <see cref="StreamState"/>.</param>
-        /// <param name="startNumber">The first number in the hls sequence.</param>
-        /// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
-        /// <returns>The command line arguments for video transcoding.</returns>
-        private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist)
+        if (state.OutputAudioSampleRate.HasValue)
         {
-            if (state.VideoStream is null)
-            {
-                return string.Empty;
-            }
+            args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
+        }
 
-            if (!state.IsOutputVideo)
-            {
-                return string.Empty;
-            }
+        args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions);
 
-            var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+        return args;
+    }
 
-            var args = "-codec:v:0 " + codec;
+    /// <summary>
+    /// Gets the video arguments for transcoding.
+    /// </summary>
+    /// <param name="state">The <see cref="StreamState"/>.</param>
+    /// <param name="startNumber">The first number in the hls sequence.</param>
+    /// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
+    /// <returns>The command line arguments for video transcoding.</returns>
+    private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist)
+    {
+        if (state.VideoStream is null)
+        {
+            return string.Empty;
+        }
 
-            if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
-            {
-                if (EncodingHelper.IsCopyCodec(codec)
-                    && (string.Equals(state.VideoStream.VideoRangeType, "DOVI", StringComparison.OrdinalIgnoreCase)
-                        || string.Equals(state.VideoStream.CodecTag, "dovi", StringComparison.OrdinalIgnoreCase)
-                        || string.Equals(state.VideoStream.CodecTag, "dvh1", StringComparison.OrdinalIgnoreCase)
-                        || string.Equals(state.VideoStream.CodecTag, "dvhe", StringComparison.OrdinalIgnoreCase)))
-                {
-                    // Prefer dvh1 to dvhe
-                    args += " -tag:v:0 dvh1 -strict -2";
-                }
-                else
-                {
-                    // Prefer hvc1 to hev1
-                    args += " -tag:v:0 hvc1";
-                }
-            }
+        if (!state.IsOutputVideo)
+        {
+            return string.Empty;
+        }
 
-            // if  (state.EnableMpegtsM2TsMode)
-            // {
-            //     args += " -mpegts_m2ts_mode 1";
-            // }
+        var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
 
-            // See if we can save come cpu cycles by avoiding encoding.
-            if (EncodingHelper.IsCopyCodec(codec))
-            {
-                // If h264_mp4toannexb is ever added, do not use it for live tv.
-                if (state.VideoStream is not null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
-                {
-                    string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream);
-                    if (!string.IsNullOrEmpty(bitStreamArgs))
-                    {
-                        args += " " + bitStreamArgs;
-                    }
-                }
+        var args = "-codec:v:0 " + codec;
 
-                args += " -start_at_zero";
+        if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+        {
+            if (EncodingHelper.IsCopyCodec(codec)
+                && (string.Equals(state.VideoStream.VideoRangeType, "DOVI", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(state.VideoStream.CodecTag, "dovi", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(state.VideoStream.CodecTag, "dvh1", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(state.VideoStream.CodecTag, "dvhe", StringComparison.OrdinalIgnoreCase)))
+            {
+                // Prefer dvh1 to dvhe
+                args += " -tag:v:0 dvh1 -strict -2";
             }
             else
             {
-                args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
+                // Prefer hvc1 to hev1
+                args += " -tag:v:0 hvc1";
+            }
+        }
 
-                // Set the key frame params for video encoding to match the hls segment time.
-                args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber);
+        // if  (state.EnableMpegtsM2TsMode)
+        // {
+        //     args += " -mpegts_m2ts_mode 1";
+        // }
 
-                // Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
-                if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
+        // See if we can save come cpu cycles by avoiding encoding.
+        if (EncodingHelper.IsCopyCodec(codec))
+        {
+            // If h264_mp4toannexb is ever added, do not use it for live tv.
+            if (state.VideoStream is not null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
+            {
+                string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream);
+                if (!string.IsNullOrEmpty(bitStreamArgs))
                 {
-                    args += " -bf 0";
+                    args += " " + bitStreamArgs;
                 }
+            }
 
-                // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0";
-
-                // video processing filters.
-                args += _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec);
+            args += " -start_at_zero";
+        }
+        else
+        {
+            args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
 
-                // -start_at_zero is necessary to use with -ss when seeking,
-                // otherwise the target position cannot be determined.
-                if (state.SubtitleStream is not null)
-                {
-                    // Disable start_at_zero for external graphical subs
-                    if (!(state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream))
-                    {
-                        args += " -start_at_zero";
-                    }
-                }
-            }
+            // Set the key frame params for video encoding to match the hls segment time.
+            args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber);
 
-            // TODO why was this not enabled for VOD?
-            if (isEventPlaylist)
+            // Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
+            if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
             {
-                args += " -flags -global_header";
+                args += " -bf 0";
             }
 
-            if (!string.IsNullOrEmpty(state.OutputVideoSync))
-            {
-                args += " -vsync " + state.OutputVideoSync;
-            }
+            // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0";
 
-            args += _encodingHelper.GetOutputFFlags(state);
+            // video processing filters.
+            args += _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec);
 
-            return args;
+            // -start_at_zero is necessary to use with -ss when seeking,
+            // otherwise the target position cannot be determined.
+            if (state.SubtitleStream is not null)
+            {
+                // Disable start_at_zero for external graphical subs
+                if (!(state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream))
+                {
+                    args += " -start_at_zero";
+                }
+            }
         }
 
-        private string GetSegmentPath(StreamState state, string playlist, int index)
+        // TODO why was this not enabled for VOD?
+        if (isEventPlaylist)
         {
-            var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException($"Provided path ({playlist}) is not valid.", nameof(playlist));
-            var filename = Path.GetFileNameWithoutExtension(playlist);
+            args += " -flags -global_header";
+        }
 
-            return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer));
+        if (!string.IsNullOrEmpty(state.OutputVideoSync))
+        {
+            args += " -vsync " + state.OutputVideoSync;
         }
 
-        private async Task<ActionResult> GetSegmentResult(
-            StreamState state,
-            string playlistPath,
-            string segmentPath,
-            string segmentExtension,
-            int segmentIndex,
-            TranscodingJobDto? transcodingJob,
-            CancellationToken cancellationToken)
+        args += _encodingHelper.GetOutputFFlags(state);
+
+        return args;
+    }
+
+    private string GetSegmentPath(StreamState state, string playlist, int index)
+    {
+        var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException($"Provided path ({playlist}) is not valid.", nameof(playlist));
+        var filename = Path.GetFileNameWithoutExtension(playlist);
+
+        return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer));
+    }
+
+    private async Task<ActionResult> GetSegmentResult(
+        StreamState state,
+        string playlistPath,
+        string segmentPath,
+        string segmentExtension,
+        int segmentIndex,
+        TranscodingJobDto? transcodingJob,
+        CancellationToken cancellationToken)
+    {
+        var segmentExists = System.IO.File.Exists(segmentPath);
+        if (segmentExists)
         {
-            var segmentExists = System.IO.File.Exists(segmentPath);
-            if (segmentExists)
+            if (transcodingJob is not null && transcodingJob.HasExited)
             {
-                if (transcodingJob is not null && transcodingJob.HasExited)
-                {
-                    // Transcoding job is over, so assume all existing files are ready
-                    _logger.LogDebug("serving up {0} as transcode is over", segmentPath);
-                    return GetSegmentResult(state, segmentPath, transcodingJob);
-                }
+                // Transcoding job is over, so assume all existing files are ready
+                _logger.LogDebug("serving up {0} as transcode is over", segmentPath);
+                return GetSegmentResult(state, segmentPath, transcodingJob);
+            }
 
-                var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
+            var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
 
-                // If requested segment is less than transcoding position, we can't transcode backwards, so assume it's ready
-                if (segmentIndex < currentTranscodingIndex)
-                {
-                    _logger.LogDebug("serving up {0} as transcode index {1} is past requested point {2}", segmentPath, currentTranscodingIndex, segmentIndex);
-                    return GetSegmentResult(state, segmentPath, transcodingJob);
-                }
+            // If requested segment is less than transcoding position, we can't transcode backwards, so assume it's ready
+            if (segmentIndex < currentTranscodingIndex)
+            {
+                _logger.LogDebug("serving up {0} as transcode index {1} is past requested point {2}", segmentPath, currentTranscodingIndex, segmentIndex);
+                return GetSegmentResult(state, segmentPath, transcodingJob);
             }
+        }
 
-            var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1);
-            if (transcodingJob is not null)
+        var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1);
+        if (transcodingJob is not null)
+        {
+            while (!cancellationToken.IsCancellationRequested && !transcodingJob.HasExited)
             {
-                while (!cancellationToken.IsCancellationRequested && !transcodingJob.HasExited)
+                // To be considered ready, the segment file has to exist AND
+                // either the transcoding job should be done or next segment should also exist
+                if (segmentExists)
                 {
-                    // To be considered ready, the segment file has to exist AND
-                    // either the transcoding job should be done or next segment should also exist
-                    if (segmentExists)
+                    if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath))
                     {
-                        if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath))
-                        {
-                            _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath);
-                            return GetSegmentResult(state, segmentPath, transcodingJob);
-                        }
+                        _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath);
+                        return GetSegmentResult(state, segmentPath, transcodingJob);
                     }
-                    else
-                    {
-                        segmentExists = System.IO.File.Exists(segmentPath);
-                        if (segmentExists)
-                        {
-                            continue; // avoid unnecessary waiting if segment just became available
-                        }
-                    }
-
-                    await Task.Delay(100, cancellationToken).ConfigureAwait(false);
-                }
-
-                if (!System.IO.File.Exists(segmentPath))
-                {
-                    _logger.LogWarning("cannot serve {0} as transcoding quit before we got there", segmentPath);
                 }
                 else
                 {
-                    _logger.LogDebug("serving {0} as it's on disk and transcoding stopped", segmentPath);
+                    segmentExists = System.IO.File.Exists(segmentPath);
+                    if (segmentExists)
+                    {
+                        continue; // avoid unnecessary waiting if segment just became available
+                    }
                 }
 
-                cancellationToken.ThrowIfCancellationRequested();
+                await Task.Delay(100, cancellationToken).ConfigureAwait(false);
+            }
+
+            if (!System.IO.File.Exists(segmentPath))
+            {
+                _logger.LogWarning("cannot serve {0} as transcoding quit before we got there", segmentPath);
             }
             else
             {
-                _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath);
+                _logger.LogDebug("serving {0} as it's on disk and transcoding stopped", segmentPath);
             }
 
-            return GetSegmentResult(state, segmentPath, transcodingJob);
+            cancellationToken.ThrowIfCancellationRequested();
         }
-
-        private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJobDto? transcodingJob)
+        else
         {
-            var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks;
-
-            Response.OnCompleted(() =>
-            {
-                _logger.LogDebug("Finished serving {SegmentPath}", segmentPath);
-                if (transcodingJob is not null)
-                {
-                    transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks);
-                    _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob);
-                }
+            _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath);
+        }
 
-                return Task.CompletedTask;
-            });
+        return GetSegmentResult(state, segmentPath, transcodingJob);
+    }
 
-            return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath));
-        }
+    private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJobDto? transcodingJob)
+    {
+        var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks;
 
-        private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension)
+        Response.OnCompleted(() =>
         {
-            var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType);
-
-            if (job is null || job.HasExited)
+            _logger.LogDebug("Finished serving {SegmentPath}", segmentPath);
+            if (transcodingJob is not null)
             {
-                return null;
+                transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks);
+                _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob);
             }
 
-            var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem);
+            return Task.CompletedTask;
+        });
 
-            if (file is null)
-            {
-                return null;
-            }
-
-            var playlistFilename = Path.GetFileNameWithoutExtension(playlist);
+        return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath));
+    }
 
-            var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length);
+    private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension)
+    {
+        var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType);
 
-            return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture);
+        if (job is null || job.HasExited)
+        {
+            return null;
         }
 
-        private static FileSystemMetadata? GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem)
+        var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem);
+
+        if (file is null)
         {
-            var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException("Path can't be a root directory.", nameof(playlist));
+            return null;
+        }
 
-            var filePrefix = Path.GetFileNameWithoutExtension(playlist);
+        var playlistFilename = Path.GetFileNameWithoutExtension(playlist);
 
-            try
-            {
-                return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false)
-                    .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase))
-                    .OrderByDescending(fileSystem.GetLastWriteTimeUtc)
-                    .FirstOrDefault();
-            }
-            catch (IOException)
-            {
-                return null;
-            }
-        }
+        var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length);
+
+        return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture);
+    }
+
+    private static FileSystemMetadata? GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem)
+    {
+        var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException("Path can't be a root directory.", nameof(playlist));
 
-        private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount)
+        var filePrefix = Path.GetFileNameWithoutExtension(playlist);
+
+        try
+        {
+            return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false)
+                .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase))
+                .OrderByDescending(fileSystem.GetLastWriteTimeUtc)
+                .FirstOrDefault();
+        }
+        catch (IOException)
         {
-            var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem);
+            return null;
+        }
+    }
 
-            if (file is not null)
-            {
-                DeleteFile(file.FullName, retryCount);
-            }
+    private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount)
+    {
+        var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem);
+
+        if (file is not null)
+        {
+            DeleteFile(file.FullName, retryCount);
         }
+    }
 
-        private void DeleteFile(string path, int retryCount)
+    private void DeleteFile(string path, int retryCount)
+    {
+        if (retryCount >= 5)
         {
-            if (retryCount >= 5)
-            {
-                return;
-            }
+            return;
+        }
 
-            _logger.LogDebug("Deleting partial HLS file {Path}", path);
+        _logger.LogDebug("Deleting partial HLS file {Path}", path);
 
-            try
-            {
-                _fileSystem.DeleteFile(path);
-            }
-            catch (IOException ex)
-            {
-                _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
+        try
+        {
+            _fileSystem.DeleteFile(path);
+        }
+        catch (IOException ex)
+        {
+            _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
 
-                var task = Task.Delay(100);
-                task.Wait();
-                DeleteFile(path, retryCount + 1);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
-            }
+            var task = Task.Delay(100);
+            task.Wait();
+            DeleteFile(path, retryCount + 1);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
         }
     }
 }

+ 142 - 143
Jellyfin.Api/Controllers/EnvironmentController.cs

@@ -12,186 +12,185 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Environment Controller.
+/// </summary>
+[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+public class EnvironmentController : BaseJellyfinApiController
 {
+    private const char UncSeparator = '\\';
+    private const string UncStartPrefix = @"\\";
+
+    private readonly IFileSystem _fileSystem;
+    private readonly ILogger<EnvironmentController> _logger;
+
     /// <summary>
-    /// Environment Controller.
+    /// Initializes a new instance of the <see cref="EnvironmentController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
-    public class EnvironmentController : BaseJellyfinApiController
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{EnvironmentController}"/> interface.</param>
+    public EnvironmentController(IFileSystem fileSystem, ILogger<EnvironmentController> logger)
     {
-        private const char UncSeparator = '\\';
-        private const string UncStartPrefix = @"\\";
-
-        private readonly IFileSystem _fileSystem;
-        private readonly ILogger<EnvironmentController> _logger;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="EnvironmentController"/> class.
-        /// </summary>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{EnvironmentController}"/> interface.</param>
-        public EnvironmentController(IFileSystem fileSystem, ILogger<EnvironmentController> logger)
-        {
-            _fileSystem = fileSystem;
-            _logger = logger;
-        }
+        _fileSystem = fileSystem;
+        _logger = logger;
+    }
 
-        /// <summary>
-        /// Gets the contents of a given directory in the file system.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="includeFiles">An optional filter to include or exclude files from the results. true/false.</param>
-        /// <param name="includeDirectories">An optional filter to include or exclude folders from the results. true/false.</param>
-        /// <response code="200">Directory contents returned.</response>
-        /// <returns>Directory contents.</returns>
-        [HttpGet("DirectoryContents")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public IEnumerable<FileSystemEntryInfo> GetDirectoryContents(
-            [FromQuery, Required] string path,
-            [FromQuery] bool includeFiles = false,
-            [FromQuery] bool includeDirectories = false)
+    /// <summary>
+    /// Gets the contents of a given directory in the file system.
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <param name="includeFiles">An optional filter to include or exclude files from the results. true/false.</param>
+    /// <param name="includeDirectories">An optional filter to include or exclude folders from the results. true/false.</param>
+    /// <response code="200">Directory contents returned.</response>
+    /// <returns>Directory contents.</returns>
+    [HttpGet("DirectoryContents")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public IEnumerable<FileSystemEntryInfo> GetDirectoryContents(
+        [FromQuery, Required] string path,
+        [FromQuery] bool includeFiles = false,
+        [FromQuery] bool includeDirectories = false)
+    {
+        if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase)
+            && path.LastIndexOf(UncSeparator) == 1)
         {
-            if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase)
-                && path.LastIndexOf(UncSeparator) == 1)
-            {
-                return Array.Empty<FileSystemEntryInfo>();
-            }
+            return Array.Empty<FileSystemEntryInfo>();
+        }
 
-            var entries =
-                _fileSystem.GetFileSystemEntries(path)
-                    .Where(i => (i.IsDirectory && includeDirectories) || (!i.IsDirectory && includeFiles))
-                    .OrderBy(i => i.FullName);
+        var entries =
+            _fileSystem.GetFileSystemEntries(path)
+                .Where(i => (i.IsDirectory && includeDirectories) || (!i.IsDirectory && includeFiles))
+                .OrderBy(i => i.FullName);
 
-            return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File));
-        }
+        return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File));
+    }
 
-        /// <summary>
-        /// Validates path.
-        /// </summary>
-        /// <param name="validatePathDto">Validate request object.</param>
-        /// <response code="204">Path validated.</response>
-        /// <response code="404">Path not found.</response>
-        /// <returns>Validation status.</returns>
-        [HttpPost("ValidatePath")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto)
+    /// <summary>
+    /// Validates path.
+    /// </summary>
+    /// <param name="validatePathDto">Validate request object.</param>
+    /// <response code="204">Path validated.</response>
+    /// <response code="404">Path not found.</response>
+    /// <returns>Validation status.</returns>
+    [HttpPost("ValidatePath")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto)
+    {
+        if (validatePathDto.IsFile.HasValue)
         {
-            if (validatePathDto.IsFile.HasValue)
+            if (validatePathDto.IsFile.Value)
             {
-                if (validatePathDto.IsFile.Value)
+                if (!System.IO.File.Exists(validatePathDto.Path))
                 {
-                    if (!System.IO.File.Exists(validatePathDto.Path))
-                    {
-                        return NotFound();
-                    }
-                }
-                else
-                {
-                    if (!Directory.Exists(validatePathDto.Path))
-                    {
-                        return NotFound();
-                    }
+                    return NotFound();
                 }
             }
             else
             {
-                if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path))
+                if (!Directory.Exists(validatePathDto.Path))
                 {
                     return NotFound();
                 }
+            }
+        }
+        else
+        {
+            if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path))
+            {
+                return NotFound();
+            }
 
-                if (validatePathDto.ValidateWritable)
+            if (validatePathDto.ValidateWritable)
+            {
+                if (validatePathDto.Path is null)
                 {
-                    if (validatePathDto.Path is null)
-                    {
-                        throw new ResourceNotFoundException(nameof(validatePathDto.Path));
-                    }
+                    throw new ResourceNotFoundException(nameof(validatePathDto.Path));
+                }
 
-                    var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString());
-                    try
-                    {
-                        System.IO.File.WriteAllText(file, string.Empty);
-                    }
-                    finally
+                var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString());
+                try
+                {
+                    System.IO.File.WriteAllText(file, string.Empty);
+                }
+                finally
+                {
+                    if (System.IO.File.Exists(file))
                     {
-                        if (System.IO.File.Exists(file))
-                        {
-                            System.IO.File.Delete(file);
-                        }
+                        System.IO.File.Delete(file);
                     }
                 }
             }
-
-            return NoContent();
         }
 
-        /// <summary>
-        /// Gets network paths.
-        /// </summary>
-        /// <response code="200">Empty array returned.</response>
-        /// <returns>List of entries.</returns>
-        [Obsolete("This endpoint is obsolete.")]
-        [HttpGet("NetworkShares")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares()
-        {
-            _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares");
-            return Array.Empty<FileSystemEntryInfo>();
-        }
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Gets available drives from the server's file system.
-        /// </summary>
-        /// <response code="200">List of entries returned.</response>
-        /// <returns>List of entries.</returns>
-        [HttpGet("Drives")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public IEnumerable<FileSystemEntryInfo> GetDrives()
-        {
-            return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory));
-        }
+    /// <summary>
+    /// Gets network paths.
+    /// </summary>
+    /// <response code="200">Empty array returned.</response>
+    /// <returns>List of entries.</returns>
+    [Obsolete("This endpoint is obsolete.")]
+    [HttpGet("NetworkShares")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares()
+    {
+        _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares");
+        return Array.Empty<FileSystemEntryInfo>();
+    }
 
-        /// <summary>
-        /// Gets the parent path of a given path.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <returns>Parent path.</returns>
-        [HttpGet("ParentPath")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<string?> GetParentPath([FromQuery, Required] string path)
+    /// <summary>
+    /// Gets available drives from the server's file system.
+    /// </summary>
+    /// <response code="200">List of entries returned.</response>
+    /// <returns>List of entries.</returns>
+    [HttpGet("Drives")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public IEnumerable<FileSystemEntryInfo> GetDrives()
+    {
+        return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory));
+    }
+
+    /// <summary>
+    /// Gets the parent path of a given path.
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <returns>Parent path.</returns>
+    [HttpGet("ParentPath")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<string?> GetParentPath([FromQuery, Required] string path)
+    {
+        string? parent = Path.GetDirectoryName(path);
+        if (string.IsNullOrEmpty(parent))
         {
-            string? parent = Path.GetDirectoryName(path);
-            if (string.IsNullOrEmpty(parent))
+            // Check if unc share
+            var index = path.LastIndexOf(UncSeparator);
+
+            if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0)
             {
-                // Check if unc share
-                var index = path.LastIndexOf(UncSeparator);
+                parent = path.Substring(0, index);
 
-                if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0)
+                if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator)))
                 {
-                    parent = path.Substring(0, index);
-
-                    if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator)))
-                    {
-                        parent = null;
-                    }
+                    parent = null;
                 }
             }
-
-            return parent;
         }
 
-        /// <summary>
-        /// Get Default directory browser.
-        /// </summary>
-        /// <response code="200">Default directory browser returned.</response>
-        /// <returns>Default directory browser.</returns>
-        [HttpGet("DefaultDirectoryBrowser")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<DefaultDirectoryBrowserInfoDto> GetDefaultDirectoryBrowser()
-        {
-            return new DefaultDirectoryBrowserInfoDto();
-        }
+        return parent;
+    }
+
+    /// <summary>
+    /// Get Default directory browser.
+    /// </summary>
+    /// <response code="200">Default directory browser returned.</response>
+    /// <returns>Default directory browser.</returns>
+    [HttpGet("DefaultDirectoryBrowser")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<DefaultDirectoryBrowserInfoDto> GetDefaultDirectoryBrowser()
+    {
+        return new DefaultDirectoryBrowserInfoDto();
     }
 }

+ 179 - 180
Jellyfin.Api/Controllers/FilterController.cs

@@ -12,205 +12,204 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Filters controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class FilterController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IUserManager _userManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="FilterController"/> class.
+    /// </summary>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    public FilterController(ILibraryManager libraryManager, IUserManager userManager)
+    {
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+    }
+
     /// <summary>
-    /// Filters controller.
+    /// Gets legacy query filters.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class FilterController : BaseJellyfinApiController
+    /// <param name="userId">Optional. User id.</param>
+    /// <param name="parentId">Optional. Parent id.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <response code="200">Legacy filters retrieved.</response>
+    /// <returns>Legacy query filters.</returns>
+    [HttpGet("Items/Filters")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
+        [FromQuery] Guid? userId,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="FilterController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        public FilterController(ILibraryManager libraryManager, IUserManager userManager)
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+
+        BaseItem? item = null;
+        if (includeItemTypes.Length != 1
+            || !(includeItemTypes[0] == BaseItemKind.BoxSet
+                 || includeItemTypes[0] == BaseItemKind.Playlist
+                 || includeItemTypes[0] == BaseItemKind.Trailer
+                 || includeItemTypes[0] == BaseItemKind.Program))
         {
-            _libraryManager = libraryManager;
-            _userManager = userManager;
+            item = _libraryManager.GetParentItem(parentId, user?.Id);
         }
 
-        /// <summary>
-        /// Gets legacy query filters.
-        /// </summary>
-        /// <param name="userId">Optional. User id.</param>
-        /// <param name="parentId">Optional. Parent id.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <response code="200">Legacy filters retrieved.</response>
-        /// <returns>Legacy query filters.</returns>
-        [HttpGet("Items/Filters")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
-            [FromQuery] Guid? userId,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes)
+        var query = new InternalItemsQuery
         {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-
-            BaseItem? item = null;
-            if (includeItemTypes.Length != 1
-                || !(includeItemTypes[0] == BaseItemKind.BoxSet
-                     || includeItemTypes[0] == BaseItemKind.Playlist
-                     || includeItemTypes[0] == BaseItemKind.Trailer
-                     || includeItemTypes[0] == BaseItemKind.Program))
+            User = user,
+            MediaTypes = mediaTypes,
+            IncludeItemTypes = includeItemTypes,
+            Recursive = true,
+            EnableTotalRecordCount = false,
+            DtoOptions = new DtoOptions
             {
-                item = _libraryManager.GetParentItem(parentId, user?.Id);
+                Fields = new[] { ItemFields.Genres, ItemFields.Tags },
+                EnableImages = false,
+                EnableUserData = false
             }
+        };
 
-            var query = new InternalItemsQuery
-            {
-                User = user,
-                MediaTypes = mediaTypes,
-                IncludeItemTypes = includeItemTypes,
-                Recursive = true,
-                EnableTotalRecordCount = false,
-                DtoOptions = new DtoOptions
-                {
-                    Fields = new[] { ItemFields.Genres, ItemFields.Tags },
-                    EnableImages = false,
-                    EnableUserData = false
-                }
-            };
-
-            if (item is not Folder folder)
-            {
-                return new QueryFiltersLegacy();
-            }
-
-            var itemList = folder.GetItemList(query);
-            return new QueryFiltersLegacy
-            {
-                Years = itemList.Select(i => i.ProductionYear ?? -1)
-                    .Where(i => i > 0)
-                    .Distinct()
-                    .Order()
-                    .ToArray(),
-
-                Genres = itemList.SelectMany(i => i.Genres)
-                    .DistinctNames()
-                    .Order()
-                    .ToArray(),
-
-                Tags = itemList
-                    .SelectMany(i => i.Tags)
-                    .Distinct(StringComparer.OrdinalIgnoreCase)
-                    .Order()
-                    .ToArray(),
-
-                OfficialRatings = itemList
-                    .Select(i => i.OfficialRating)
-                    .Where(i => !string.IsNullOrWhiteSpace(i))
-                    .Distinct(StringComparer.OrdinalIgnoreCase)
-                    .Order()
-                    .ToArray()
-            };
+        if (item is not Folder folder)
+        {
+            return new QueryFiltersLegacy();
         }
 
-        /// <summary>
-        /// Gets query filters.
-        /// </summary>
-        /// <param name="userId">Optional. User id.</param>
-        /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="isAiring">Optional. Is item airing.</param>
-        /// <param name="isMovie">Optional. Is item movie.</param>
-        /// <param name="isSports">Optional. Is item sports.</param>
-        /// <param name="isKids">Optional. Is item kids.</param>
-        /// <param name="isNews">Optional. Is item news.</param>
-        /// <param name="isSeries">Optional. Is item series.</param>
-        /// <param name="recursive">Optional. Search recursive.</param>
-        /// <response code="200">Filters retrieved.</response>
-        /// <returns>Query filters.</returns>
-        [HttpGet("Items/Filters2")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryFilters> GetQueryFilters(
-            [FromQuery] Guid? userId,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery] bool? isAiring,
-            [FromQuery] bool? isMovie,
-            [FromQuery] bool? isSports,
-            [FromQuery] bool? isKids,
-            [FromQuery] bool? isNews,
-            [FromQuery] bool? isSeries,
-            [FromQuery] bool? recursive)
+        var itemList = folder.GetItemList(query);
+        return new QueryFiltersLegacy
         {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-
-            BaseItem? parentItem = null;
-            if (includeItemTypes.Length == 1
-                && (includeItemTypes[0] == BaseItemKind.BoxSet
-                    || includeItemTypes[0] == BaseItemKind.Playlist
-                    || includeItemTypes[0] == BaseItemKind.Trailer
-                    || includeItemTypes[0] == BaseItemKind.Program))
-            {
-                parentItem = null;
-            }
-            else if (parentId.HasValue)
-            {
-                parentItem = _libraryManager.GetItemById(parentId.Value);
-            }
+            Years = itemList.Select(i => i.ProductionYear ?? -1)
+                .Where(i => i > 0)
+                .Distinct()
+                .Order()
+                .ToArray(),
+
+            Genres = itemList.SelectMany(i => i.Genres)
+                .DistinctNames()
+                .Order()
+                .ToArray(),
+
+            Tags = itemList
+                .SelectMany(i => i.Tags)
+                .Distinct(StringComparer.OrdinalIgnoreCase)
+                .Order()
+                .ToArray(),
+
+            OfficialRatings = itemList
+                .Select(i => i.OfficialRating)
+                .Where(i => !string.IsNullOrWhiteSpace(i))
+                .Distinct(StringComparer.OrdinalIgnoreCase)
+                .Order()
+                .ToArray()
+        };
+    }
 
-            var filters = new QueryFilters();
-            var genreQuery = new InternalItemsQuery(user)
-            {
-                IncludeItemTypes = includeItemTypes,
-                DtoOptions = new DtoOptions
-                {
-                    Fields = Array.Empty<ItemFields>(),
-                    EnableImages = false,
-                    EnableUserData = false
-                },
-                IsAiring = isAiring,
-                IsMovie = isMovie,
-                IsSports = isSports,
-                IsKids = isKids,
-                IsNews = isNews,
-                IsSeries = isSeries
-            };
-
-            if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder)
-            {
-                genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
-            }
-            else
+    /// <summary>
+    /// Gets query filters.
+    /// </summary>
+    /// <param name="userId">Optional. User id.</param>
+    /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="isAiring">Optional. Is item airing.</param>
+    /// <param name="isMovie">Optional. Is item movie.</param>
+    /// <param name="isSports">Optional. Is item sports.</param>
+    /// <param name="isKids">Optional. Is item kids.</param>
+    /// <param name="isNews">Optional. Is item news.</param>
+    /// <param name="isSeries">Optional. Is item series.</param>
+    /// <param name="recursive">Optional. Search recursive.</param>
+    /// <response code="200">Filters retrieved.</response>
+    /// <returns>Query filters.</returns>
+    [HttpGet("Items/Filters2")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryFilters> GetQueryFilters(
+        [FromQuery] Guid? userId,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery] bool? isAiring,
+        [FromQuery] bool? isMovie,
+        [FromQuery] bool? isSports,
+        [FromQuery] bool? isKids,
+        [FromQuery] bool? isNews,
+        [FromQuery] bool? isSeries,
+        [FromQuery] bool? recursive)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+
+        BaseItem? parentItem = null;
+        if (includeItemTypes.Length == 1
+            && (includeItemTypes[0] == BaseItemKind.BoxSet
+                || includeItemTypes[0] == BaseItemKind.Playlist
+                || includeItemTypes[0] == BaseItemKind.Trailer
+                || includeItemTypes[0] == BaseItemKind.Program))
+        {
+            parentItem = null;
+        }
+        else if (parentId.HasValue)
+        {
+            parentItem = _libraryManager.GetItemById(parentId.Value);
+        }
+
+        var filters = new QueryFilters();
+        var genreQuery = new InternalItemsQuery(user)
+        {
+            IncludeItemTypes = includeItemTypes,
+            DtoOptions = new DtoOptions
             {
-                genreQuery.Parent = parentItem;
-            }
+                Fields = Array.Empty<ItemFields>(),
+                EnableImages = false,
+                EnableUserData = false
+            },
+            IsAiring = isAiring,
+            IsMovie = isMovie,
+            IsSports = isSports,
+            IsKids = isKids,
+            IsNews = isNews,
+            IsSeries = isSeries
+        };
+
+        if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder)
+        {
+            genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
+        }
+        else
+        {
+            genreQuery.Parent = parentItem;
+        }
 
-            if (includeItemTypes.Length == 1
-                && (includeItemTypes[0] == BaseItemKind.MusicAlbum
-                    || includeItemTypes[0] == BaseItemKind.MusicVideo
-                    || includeItemTypes[0] == BaseItemKind.MusicArtist
-                    || includeItemTypes[0] == BaseItemKind.Audio))
+        if (includeItemTypes.Length == 1
+            && (includeItemTypes[0] == BaseItemKind.MusicAlbum
+                || includeItemTypes[0] == BaseItemKind.MusicVideo
+                || includeItemTypes[0] == BaseItemKind.MusicArtist
+                || includeItemTypes[0] == BaseItemKind.Audio))
+        {
+            filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
             {
-                filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
-                {
-                    Name = i.Item.Name,
-                    Id = i.Item.Id
-                }).ToArray();
-            }
-            else
+                Name = i.Item.Name,
+                Id = i.Item.Id
+            }).ToArray();
+        }
+        else
+        {
+            filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair
             {
-                filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair
-                {
-                    Name = i.Item.Name,
-                    Id = i.Item.Id
-                }).ToArray();
-            }
-
-            return filters;
+                Name = i.Item.Name,
+                Id = i.Item.Id
+            }).ToArray();
         }
+
+        return filters;
     }
 }

+ 161 - 162
Jellyfin.Api/Controllers/GenresController.cs

@@ -18,194 +18,193 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Genre = MediaBrowser.Controller.Entities.Genre;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The genres controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class GenresController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IDtoService _dtoService;
+
     /// <summary>
-    /// The genres controller.
+    /// Initializes a new instance of the <see cref="GenresController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class GenresController : BaseJellyfinApiController
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    public GenresController(
+        IUserManager userManager,
+        ILibraryManager libraryManager,
+        IDtoService dtoService)
     {
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="GenresController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        public GenresController(
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IDtoService dtoService)
-        {
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _dtoService = dtoService;
-        }
+        _userManager = userManager;
+        _libraryManager = libraryManager;
+        _dtoService = dtoService;
+    }
 
-        /// <summary>
-        /// Gets all genres from a given item, folder, or the entire library.
-        /// </summary>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="searchTerm">The search term.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
-        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
-        /// <response code="200">Genres returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the queryresult of genres.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetGenres(
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] string? searchTerm,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery] bool? isFavorite,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery] bool? enableImages = true,
-            [FromQuery] bool enableTotalRecordCount = true)
-        {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
+    /// <summary>
+    /// Gets all genres from a given item, folder, or the entire library.
+    /// </summary>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="searchTerm">The search term.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+    /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+    /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
+    /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
+    /// <response code="200">Genres returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the queryresult of genres.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetGenres(
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] string? searchTerm,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery] bool? isFavorite,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] Guid? userId,
+        [FromQuery] string? nameStartsWithOrGreater,
+        [FromQuery] string? nameStartsWith,
+        [FromQuery] string? nameLessThan,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery] bool? enableImages = true,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
 
-            User? user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+        User? user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-            var parentItem = _libraryManager.GetParentItem(parentId, userId);
+        var parentItem = _libraryManager.GetParentItem(parentId, userId);
 
-            var query = new InternalItemsQuery(user)
-            {
-                ExcludeItemTypes = excludeItemTypes,
-                IncludeItemTypes = includeItemTypes,
-                StartIndex = startIndex,
-                Limit = limit,
-                IsFavorite = isFavorite,
-                NameLessThan = nameLessThan,
-                NameStartsWith = nameStartsWith,
-                NameStartsWithOrGreater = nameStartsWithOrGreater,
-                DtoOptions = dtoOptions,
-                SearchTerm = searchTerm,
-                EnableTotalRecordCount = enableTotalRecordCount,
-                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
-            };
-
-            if (parentId.HasValue)
-            {
-                if (parentItem is Folder)
-                {
-                    query.AncestorIds = new[] { parentId.Value };
-                }
-                else
-                {
-                    query.ItemIds = new[] { parentId.Value };
-                }
-            }
-
-            QueryResult<(BaseItem, ItemCounts)> result;
-            if (parentItem is ICollectionFolder parentCollectionFolder
-                && (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal)
-                || string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal)))
+        var query = new InternalItemsQuery(user)
+        {
+            ExcludeItemTypes = excludeItemTypes,
+            IncludeItemTypes = includeItemTypes,
+            StartIndex = startIndex,
+            Limit = limit,
+            IsFavorite = isFavorite,
+            NameLessThan = nameLessThan,
+            NameStartsWith = nameStartsWith,
+            NameStartsWithOrGreater = nameStartsWithOrGreater,
+            DtoOptions = dtoOptions,
+            SearchTerm = searchTerm,
+            EnableTotalRecordCount = enableTotalRecordCount,
+            OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
+        };
+
+        if (parentId.HasValue)
+        {
+            if (parentItem is Folder)
             {
-                result = _libraryManager.GetMusicGenres(query);
+                query.AncestorIds = new[] { parentId.Value };
             }
             else
             {
-                result = _libraryManager.GetGenres(query);
+                query.ItemIds = new[] { parentId.Value };
             }
-
-            var shouldIncludeItemTypes = includeItemTypes.Length != 0;
-            return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
 
-        /// <summary>
-        /// Gets a genre, by name.
-        /// </summary>
-        /// <param name="genreName">The genre name.</param>
-        /// <param name="userId">The user id.</param>
-        /// <response code="200">Genres returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the genre.</returns>
-        [HttpGet("{genreName}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
+        QueryResult<(BaseItem, ItemCounts)> result;
+        if (parentItem is ICollectionFolder parentCollectionFolder
+            && (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal)
+            || string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal)))
+        {
+            result = _libraryManager.GetMusicGenres(query);
+        }
+        else
         {
-            var dtoOptions = new DtoOptions()
-                .AddClientFields(User);
+            result = _libraryManager.GetGenres(query);
+        }
 
-            Genre? item;
-            if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase))
-            {
-                item = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre);
-            }
-            else
-            {
-                item = _libraryManager.GetGenre(genreName);
-            }
+        var shouldIncludeItemTypes = includeItemTypes.Length != 0;
+        return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
+    }
 
-            item ??= new Genre();
+    /// <summary>
+    /// Gets a genre, by name.
+    /// </summary>
+    /// <param name="genreName">The genre name.</param>
+    /// <param name="userId">The user id.</param>
+    /// <response code="200">Genres returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the genre.</returns>
+    [HttpGet("{genreName}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
+    {
+        var dtoOptions = new DtoOptions()
+            .AddClientFields(User);
 
-            if (userId is null || userId.Value.Equals(default))
-            {
-                return _dtoService.GetBaseItemDto(item, dtoOptions);
-            }
+        Genre? item;
+        if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase))
+        {
+            item = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre);
+        }
+        else
+        {
+            item = _libraryManager.GetGenre(genreName);
+        }
 
-            var user = _userManager.GetUserById(userId.Value);
+        item ??= new Genre();
 
-            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+        if (userId is null || userId.Value.Equals(default))
+        {
+            return _dtoService.GetBaseItemDto(item, dtoOptions);
         }
 
-        private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind)
-            where T : BaseItem, new()
+        var user = _userManager.GetUserById(userId.Value);
+
+        return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+    }
+
+    private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind)
+        where T : BaseItem, new()
+    {
+        var result = libraryManager.GetItemList(new InternalItemsQuery
         {
-            var result = libraryManager.GetItemList(new InternalItemsQuery
-            {
-                Name = name.Replace(BaseItem.SlugChar, '&'),
-                IncludeItemTypes = new[] { baseItemKind },
-                DtoOptions = dtoOptions
-            }).OfType<T>().FirstOrDefault();
+            Name = name.Replace(BaseItem.SlugChar, '&'),
+            IncludeItemTypes = new[] { baseItemKind },
+            DtoOptions = dtoOptions
+        }).OfType<T>().FirstOrDefault();
 
-            result ??= libraryManager.GetItemList(new InternalItemsQuery
-            {
-                Name = name.Replace(BaseItem.SlugChar, '/'),
-                IncludeItemTypes = new[] { baseItemKind },
-                DtoOptions = dtoOptions
-            }).OfType<T>().FirstOrDefault();
+        result ??= libraryManager.GetItemList(new InternalItemsQuery
+        {
+            Name = name.Replace(BaseItem.SlugChar, '/'),
+            IncludeItemTypes = new[] { baseItemKind },
+            DtoOptions = dtoOptions
+        }).OfType<T>().FirstOrDefault();
 
-            result ??= libraryManager.GetItemList(new InternalItemsQuery
-            {
-                Name = name.Replace(BaseItem.SlugChar, '?'),
-                IncludeItemTypes = new[] { baseItemKind },
-                DtoOptions = dtoOptions
-            }).OfType<T>().FirstOrDefault();
+        result ??= libraryManager.GetItemList(new InternalItemsQuery
+        {
+            Name = name.Replace(BaseItem.SlugChar, '?'),
+            IncludeItemTypes = new[] { baseItemKind },
+            DtoOptions = dtoOptions
+        }).OfType<T>().FirstOrDefault();
 
-            return result;
-        }
+        return result;
     }
 }

+ 147 - 148
Jellyfin.Api/Controllers/HlsSegmentController.cs

@@ -15,178 +15,177 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The hls segment controller.
+/// </summary>
+[Route("")]
+public class HlsSegmentController : BaseJellyfinApiController
 {
+    private readonly IFileSystem _fileSystem;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly TranscodingJobHelper _transcodingJobHelper;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="HlsSegmentController"/> class.
+    /// </summary>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="transcodingJobHelper">Initialized instance of the <see cref="TranscodingJobHelper"/>.</param>
+    public HlsSegmentController(
+        IFileSystem fileSystem,
+        IServerConfigurationManager serverConfigurationManager,
+        TranscodingJobHelper transcodingJobHelper)
+    {
+        _fileSystem = fileSystem;
+        _serverConfigurationManager = serverConfigurationManager;
+        _transcodingJobHelper = transcodingJobHelper;
+    }
+
     /// <summary>
-    /// The hls segment controller.
+    /// Gets the specified audio segment for an audio item.
     /// </summary>
-    [Route("")]
-    public class HlsSegmentController : BaseJellyfinApiController
+    /// <param name="itemId">The item id.</param>
+    /// <param name="segmentId">The segment id.</param>
+    /// <response code="200">Hls audio segment returned.</response>
+    /// <returns>A <see cref="FileStreamResult"/> containing the audio stream.</returns>
+    // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
+    // [Authenticated]
+    [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.mp3", Name = "GetHlsAudioSegmentLegacyMp3")]
+    [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.aac", Name = "GetHlsAudioSegmentLegacyAac")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesAudioFile]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
+    public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
     {
-        private readonly IFileSystem _fileSystem;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly TranscodingJobHelper _transcodingJobHelper;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="HlsSegmentController"/> class.
-        /// </summary>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="transcodingJobHelper">Initialized instance of the <see cref="TranscodingJobHelper"/>.</param>
-        public HlsSegmentController(
-            IFileSystem fileSystem,
-            IServerConfigurationManager serverConfigurationManager,
-            TranscodingJobHelper transcodingJobHelper)
+        // TODO: Deprecate with new iOS app
+        var file = segmentId + Path.GetExtension(Request.Path);
+        var transcodePath = _serverConfigurationManager.GetTranscodePath();
+        file = Path.GetFullPath(Path.Combine(transcodePath, file));
+        var fileDir = Path.GetDirectoryName(file);
+        if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture))
         {
-            _fileSystem = fileSystem;
-            _serverConfigurationManager = serverConfigurationManager;
-            _transcodingJobHelper = transcodingJobHelper;
+            return BadRequest("Invalid segment.");
         }
 
-        /// <summary>
-        /// Gets the specified audio segment for an audio item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="segmentId">The segment id.</param>
-        /// <response code="200">Hls audio segment returned.</response>
-        /// <returns>A <see cref="FileStreamResult"/> containing the audio stream.</returns>
-        // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
-        // [Authenticated]
-        [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.mp3", Name = "GetHlsAudioSegmentLegacyMp3")]
-        [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.aac", Name = "GetHlsAudioSegmentLegacyAac")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesAudioFile]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
-        public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
-        {
-            // TODO: Deprecate with new iOS app
-            var file = segmentId + Path.GetExtension(Request.Path);
-            var transcodePath = _serverConfigurationManager.GetTranscodePath();
-            file = Path.GetFullPath(Path.Combine(transcodePath, file));
-            var fileDir = Path.GetDirectoryName(file);
-            if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture))
-            {
-                return BadRequest("Invalid segment.");
-            }
+        return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file));
+    }
 
-            return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file));
+    /// <summary>
+    /// Gets a hls video playlist.
+    /// </summary>
+    /// <param name="itemId">The video id.</param>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <response code="200">Hls video playlist returned.</response>
+    /// <returns>A <see cref="FileStreamResult"/> containing the playlist.</returns>
+    [HttpGet("Videos/{itemId}/hls/{playlistId}/stream.m3u8")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
+    public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
+    {
+        var file = playlistId + Path.GetExtension(Request.Path);
+        var transcodePath = _serverConfigurationManager.GetTranscodePath();
+        file = Path.GetFullPath(Path.Combine(transcodePath, file));
+        var fileDir = Path.GetDirectoryName(file);
+        if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) || Path.GetExtension(file) != ".m3u8")
+        {
+            return BadRequest("Invalid segment.");
         }
 
-        /// <summary>
-        /// Gets a hls video playlist.
-        /// </summary>
-        /// <param name="itemId">The video id.</param>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <response code="200">Hls video playlist returned.</response>
-        /// <returns>A <see cref="FileStreamResult"/> containing the playlist.</returns>
-        [HttpGet("Videos/{itemId}/hls/{playlistId}/stream.m3u8")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
-        public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
-        {
-            var file = playlistId + Path.GetExtension(Request.Path);
-            var transcodePath = _serverConfigurationManager.GetTranscodePath();
-            file = Path.GetFullPath(Path.Combine(transcodePath, file));
-            var fileDir = Path.GetDirectoryName(file);
-            if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) || Path.GetExtension(file) != ".m3u8")
-            {
-                return BadRequest("Invalid segment.");
-            }
+        return GetFileResult(file, file);
+    }
 
-            return GetFileResult(file, file);
-        }
+    /// <summary>
+    /// Stops an active encoding.
+    /// </summary>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <response code="204">Encoding stopped successfully.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpDelete("Videos/ActiveEncodings")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult StopEncodingProcess(
+        [FromQuery, Required] string deviceId,
+        [FromQuery, Required] string playSessionId)
+    {
+        _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Gets a hls video segment.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="segmentId">The segment id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <response code="200">Hls video segment returned.</response>
+    /// <response code="404">Hls segment not found.</response>
+    /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns>
+    // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
+    // [Authenticated]
+    [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesVideoFile]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
+    public ActionResult GetHlsVideoSegmentLegacy(
+        [FromRoute, Required] string itemId,
+        [FromRoute, Required] string playlistId,
+        [FromRoute, Required] string segmentId,
+        [FromRoute, Required] string segmentContainer)
+    {
+        var file = segmentId + Path.GetExtension(Request.Path);
+        var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
 
-        /// <summary>
-        /// Stops an active encoding.
-        /// </summary>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <response code="204">Encoding stopped successfully.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpDelete("Videos/ActiveEncodings")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult StopEncodingProcess(
-            [FromQuery, Required] string deviceId,
-            [FromQuery, Required] string playSessionId)
+        file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));
+        var fileDir = Path.GetDirectoryName(file);
+        if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture))
         {
-            _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true);
-            return NoContent();
+            return BadRequest("Invalid segment.");
         }
 
-        /// <summary>
-        /// Gets a hls video segment.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <param name="segmentId">The segment id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <response code="200">Hls video segment returned.</response>
-        /// <response code="404">Hls segment not found.</response>
-        /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns>
-        // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
-        // [Authenticated]
-        [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesVideoFile]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
-        public ActionResult GetHlsVideoSegmentLegacy(
-            [FromRoute, Required] string itemId,
-            [FromRoute, Required] string playlistId,
-            [FromRoute, Required] string segmentId,
-            [FromRoute, Required] string segmentContainer)
-        {
-            var file = segmentId + Path.GetExtension(Request.Path);
-            var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
+        var normalizedPlaylistId = playlistId;
 
-            file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));
-            var fileDir = Path.GetDirectoryName(file);
-            if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture))
+        var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
+        // Add . to start of segment container for future use.
+        segmentContainer = segmentContainer.Insert(0, ".");
+        string? playlistPath = null;
+        foreach (var path in filePaths)
+        {
+            var pathExtension = Path.GetExtension(path);
+            if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
+                 || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
+                && path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1)
             {
-                return BadRequest("Invalid segment.");
+                playlistPath = path;
+                break;
             }
+        }
 
-            var normalizedPlaylistId = playlistId;
-
-            var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
-            // Add . to start of segment container for future use.
-            segmentContainer = segmentContainer.Insert(0, ".");
-            string? playlistPath = null;
-            foreach (var path in filePaths)
-            {
-                var pathExtension = Path.GetExtension(path);
-                if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
-                     || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
-                    && path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1)
-                {
-                    playlistPath = path;
-                    break;
-                }
-            }
+        return playlistPath is null
+            ? NotFound("Hls segment not found.")
+            : GetFileResult(file, playlistPath);
+    }
 
-            return playlistPath is null
-                ? NotFound("Hls segment not found.")
-                : GetFileResult(file, playlistPath);
-        }
+    private ActionResult GetFileResult(string path, string playlistPath)
+    {
+        var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);
 
-        private ActionResult GetFileResult(string path, string playlistPath)
+        Response.OnCompleted(() =>
         {
-            var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);
-
-            Response.OnCompleted(() =>
+            if (transcodingJob is not null)
             {
-                if (transcodingJob is not null)
-                {
-                    _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob);
-                }
+                _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob);
+            }
 
-                return Task.CompletedTask;
-            });
+            return Task.CompletedTask;
+        });
 
-            return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path));
-        }
+        return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path));
     }
 }

+ 1860 - 1861
Jellyfin.Api/Controllers/ImageController.cs

@@ -30,2071 +30,2070 @@ using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 using Microsoft.Net.Http.Headers;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Image controller.
+/// </summary>
+[Route("")]
+public class ImageController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IProviderManager _providerManager;
+    private readonly IImageProcessor _imageProcessor;
+    private readonly IFileSystem _fileSystem;
+    private readonly ILogger<ImageController> _logger;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly IApplicationPaths _appPaths;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ImageController"/> class.
+    /// </summary>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+    /// <param name="imageProcessor">Instance of the <see cref="IImageProcessor"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{ImageController}"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+    public ImageController(
+        IUserManager userManager,
+        ILibraryManager libraryManager,
+        IProviderManager providerManager,
+        IImageProcessor imageProcessor,
+        IFileSystem fileSystem,
+        ILogger<ImageController> logger,
+        IServerConfigurationManager serverConfigurationManager,
+        IApplicationPaths appPaths)
+    {
+        _userManager = userManager;
+        _libraryManager = libraryManager;
+        _providerManager = providerManager;
+        _imageProcessor = imageProcessor;
+        _fileSystem = fileSystem;
+        _logger = logger;
+        _serverConfigurationManager = serverConfigurationManager;
+        _appPaths = appPaths;
+    }
+
     /// <summary>
-    /// Image controller.
+    /// Sets the user image.
     /// </summary>
-    [Route("")]
-    public class ImageController : BaseJellyfinApiController
+    /// <param name="userId">User Id.</param>
+    /// <param name="imageType">(Unused) Image type.</param>
+    /// <param name="index">(Unused) Image index.</param>
+    /// <response code="204">Image updated.</response>
+    /// <response code="403">User does not have permission to delete the image.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Users/{userId}/Images/{imageType}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [AcceptsImageFile]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> PostUserImage(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] int? index = null)
     {
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IProviderManager _providerManager;
-        private readonly IImageProcessor _imageProcessor;
-        private readonly IFileSystem _fileSystem;
-        private readonly ILogger<ImageController> _logger;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly IApplicationPaths _appPaths;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ImageController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
-        /// <param name="imageProcessor">Instance of the <see cref="IImageProcessor"/> interface.</param>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{ImageController}"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
-        public ImageController(
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IProviderManager providerManager,
-            IImageProcessor imageProcessor,
-            IFileSystem fileSystem,
-            ILogger<ImageController> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IApplicationPaths appPaths)
-        {
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _providerManager = providerManager;
-            _imageProcessor = imageProcessor;
-            _fileSystem = fileSystem;
-            _logger = logger;
-            _serverConfigurationManager = serverConfigurationManager;
-            _appPaths = appPaths;
-        }
-
-        /// <summary>
-        /// Sets the user image.
-        /// </summary>
-        /// <param name="userId">User Id.</param>
-        /// <param name="imageType">(Unused) Image type.</param>
-        /// <param name="index">(Unused) Image index.</param>
-        /// <response code="204">Image updated.</response>
-        /// <response code="403">User does not have permission to delete the image.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Users/{userId}/Images/{imageType}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [AcceptsImageFile]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> PostUserImage(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] int? index = null)
-        {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
+        }
+
+        var user = _userManager.GetUserById(userId);
+        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+        await using (memoryStream.ConfigureAwait(false))
+        {
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+            var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+            if (user.ProfileImage is not null)
             {
-                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
+                await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
             }
 
-            var user = _userManager.GetUserById(userId);
-            var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-            await using (memoryStream.ConfigureAwait(false))
-            {
-                // Handle image/png; charset=utf-8
-                var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
-                var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
-                if (user.ProfileImage is not null)
-                {
-                    await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
-                }
+            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
 
-                user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
+            await _providerManager
+                .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+                .ConfigureAwait(false);
+            await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
 
-                await _providerManager
-                    .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
-                    .ConfigureAwait(false);
-                await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+            return NoContent();
+        }
+    }
 
-                return NoContent();
-            }
+    /// <summary>
+    /// Sets the user image.
+    /// </summary>
+    /// <param name="userId">User Id.</param>
+    /// <param name="imageType">(Unused) Image type.</param>
+    /// <param name="index">(Unused) Image index.</param>
+    /// <response code="204">Image updated.</response>
+    /// <response code="403">User does not have permission to delete the image.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Users/{userId}/Images/{imageType}/{index}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [AcceptsImageFile]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> PostUserImageByIndex(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute] int index)
+    {
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
         }
 
-        /// <summary>
-        /// Sets the user image.
-        /// </summary>
-        /// <param name="userId">User Id.</param>
-        /// <param name="imageType">(Unused) Image type.</param>
-        /// <param name="index">(Unused) Image index.</param>
-        /// <response code="204">Image updated.</response>
-        /// <response code="403">User does not have permission to delete the image.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Users/{userId}/Images/{imageType}/{index}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [AcceptsImageFile]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> PostUserImageByIndex(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute] int index)
-        {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        var user = _userManager.GetUserById(userId);
+        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+        await using (memoryStream.ConfigureAwait(false))
+        {
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+            var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+            if (user.ProfileImage is not null)
             {
-                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
+                await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
             }
 
-            var user = _userManager.GetUserById(userId);
-            var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-            await using (memoryStream.ConfigureAwait(false))
-            {
-                // Handle image/png; charset=utf-8
-                var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
-                var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
-                if (user.ProfileImage is not null)
-                {
-                    await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
-                }
+            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
+
+            await _providerManager
+                .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+                .ConfigureAwait(false);
+            await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
 
-                user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
+            return NoContent();
+        }
+    }
 
-                await _providerManager
-                    .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
-                    .ConfigureAwait(false);
-                await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+    /// <summary>
+    /// Delete the user's image.
+    /// </summary>
+    /// <param name="userId">User Id.</param>
+    /// <param name="imageType">(Unused) Image type.</param>
+    /// <param name="index">(Unused) Image index.</param>
+    /// <response code="204">Image deleted.</response>
+    /// <response code="403">User does not have permission to delete the image.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Users/{userId}/Images/{imageType}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public async Task<ActionResult> DeleteUserImage(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] int? index = null)
+    {
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
+        }
 
-                return NoContent();
-            }
+        var user = _userManager.GetUserById(userId);
+        if (user?.ProfileImage is null)
+        {
+            return NoContent();
         }
 
-        /// <summary>
-        /// Delete the user's image.
-        /// </summary>
-        /// <param name="userId">User Id.</param>
-        /// <param name="imageType">(Unused) Image type.</param>
-        /// <param name="index">(Unused) Image index.</param>
-        /// <response code="204">Image deleted.</response>
-        /// <response code="403">User does not have permission to delete the image.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Users/{userId}/Images/{imageType}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        public async Task<ActionResult> DeleteUserImage(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] int? index = null)
-        {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
-            {
-                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
-            }
+        try
+        {
+            System.IO.File.Delete(user.ProfileImage.Path);
+        }
+        catch (IOException e)
+        {
+            _logger.LogError(e, "Error deleting user profile image:");
+        }
 
-            var user = _userManager.GetUserById(userId);
-            if (user?.ProfileImage is null)
-            {
-                return NoContent();
-            }
+        await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            try
-            {
-                System.IO.File.Delete(user.ProfileImage.Path);
-            }
-            catch (IOException e)
-            {
-                _logger.LogError(e, "Error deleting user profile image:");
-            }
+    /// <summary>
+    /// Delete the user's image.
+    /// </summary>
+    /// <param name="userId">User Id.</param>
+    /// <param name="imageType">(Unused) Image type.</param>
+    /// <param name="index">(Unused) Image index.</param>
+    /// <response code="204">Image deleted.</response>
+    /// <response code="403">User does not have permission to delete the image.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Users/{userId}/Images/{imageType}/{index}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public async Task<ActionResult> DeleteUserImageByIndex(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute] int index)
+    {
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
+        }
 
-            await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+        var user = _userManager.GetUserById(userId);
+        if (user?.ProfileImage is null)
+        {
             return NoContent();
         }
 
-        /// <summary>
-        /// Delete the user's image.
-        /// </summary>
-        /// <param name="userId">User Id.</param>
-        /// <param name="imageType">(Unused) Image type.</param>
-        /// <param name="index">(Unused) Image index.</param>
-        /// <response code="204">Image deleted.</response>
-        /// <response code="403">User does not have permission to delete the image.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Users/{userId}/Images/{imageType}/{index}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        public async Task<ActionResult> DeleteUserImageByIndex(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute] int index)
-        {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
-            {
-                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
-            }
+        try
+        {
+            System.IO.File.Delete(user.ProfileImage.Path);
+        }
+        catch (IOException e)
+        {
+            _logger.LogError(e, "Error deleting user profile image:");
+        }
 
-            var user = _userManager.GetUserById(userId);
-            if (user?.ProfileImage is null)
-            {
-                return NoContent();
-            }
+        await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            try
-            {
-                System.IO.File.Delete(user.ProfileImage.Path);
-            }
-            catch (IOException e)
-            {
-                _logger.LogError(e, "Error deleting user profile image:");
-            }
+    /// <summary>
+    /// Delete an item's image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">The image index.</param>
+    /// <response code="204">Image deleted.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpDelete("Items/{itemId}/Images/{imageType}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> DeleteItemImage(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
+        }
 
-            await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
-            return NoContent();
+        await item.DeleteImageAsync(imageType, imageIndex ?? 0).ConfigureAwait(false);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Delete an item's image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">The image index.</param>
+    /// <response code="204">Image deleted.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> DeleteItemImageByIndex(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute] int imageIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Delete an item's image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">The image index.</param>
-        /// <response code="204">Image deleted.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpDelete("Items/{itemId}/Images/{imageType}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> DeleteItemImage(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            await item.DeleteImageAsync(imageType, imageIndex ?? 0).ConfigureAwait(false);
-            return NoContent();
+    /// <summary>
+    /// Set item image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <response code="204">Image saved.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpPost("Items/{itemId}/Images/{imageType}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [AcceptsImageFile]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> SetItemImage(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Delete an item's image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">The image index.</param>
-        /// <response code="204">Image deleted.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> DeleteItemImageByIndex(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute] int imageIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+        await using (memoryStream.ConfigureAwait(false))
+        {
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+            await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+            await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
 
-            await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false);
             return NoContent();
         }
+    }
 
-        /// <summary>
-        /// Set item image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <response code="204">Image saved.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpPost("Items/{itemId}/Images/{imageType}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [AcceptsImageFile]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> SetItemImage(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+    /// <summary>
+    /// Set item image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">(Unused) Image index.</param>
+    /// <response code="204">Image saved.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [AcceptsImageFile]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> SetItemImageByIndex(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute] int imageIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
+        }
 
-            var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-            await using (memoryStream.ConfigureAwait(false))
-            {
-                // Handle image/png; charset=utf-8
-                var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
-                await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
-                await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+        await using (memoryStream.ConfigureAwait(false))
+        {
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+            await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+            await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
 
-                return NoContent();
-            }
+            return NoContent();
         }
+    }
 
-        /// <summary>
-        /// Set item image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">(Unused) Image index.</param>
-        /// <response code="204">Image saved.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [AcceptsImageFile]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> SetItemImageByIndex(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute] int imageIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+    /// <summary>
+    /// Updates the index for an item image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Old image index.</param>
+    /// <param name="newIndex">New image index.</param>
+    /// <response code="204">Image index updated.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}/Index")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> UpdateItemImageIndex(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery, Required] int newIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
+        }
 
-            var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-            await using (memoryStream.ConfigureAwait(false))
-            {
-                // Handle image/png; charset=utf-8
-                var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
-                await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
-                await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+        await item.SwapImagesAsync(imageType, imageIndex, newIndex).ConfigureAwait(false);
+        return NoContent();
+    }
 
-                return NoContent();
-            }
+    /// <summary>
+    /// Get item image infos.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Item images returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>The list of image infos on success, or <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpGet("Items/{itemId}/Images")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Updates the index for an item image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Old image index.</param>
-        /// <param name="newIndex">New image index.</param>
-        /// <response code="204">Image index updated.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}/Index")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> UpdateItemImageIndex(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery, Required] int newIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        var list = new List<ImageInfo>();
+        var itemImages = item.ImageInfos;
 
-            await item.SwapImagesAsync(imageType, imageIndex, newIndex).ConfigureAwait(false);
-            return NoContent();
+        if (itemImages.Length == 0)
+        {
+            // short-circuit
+            return list;
         }
 
-        /// <summary>
-        /// Get item image infos.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">Item images returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>The list of image infos on success, or <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpGet("Items/{itemId}/Images")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        await _libraryManager.UpdateImagesAsync(item).ConfigureAwait(false); // this makes sure dimensions and hashes are correct
 
-            var list = new List<ImageInfo>();
-            var itemImages = item.ImageInfos;
-
-            if (itemImages.Length == 0)
+        foreach (var image in itemImages)
+        {
+            if (!item.AllowsMultipleImages(image.Type))
             {
-                // short-circuit
-                return list;
-            }
+                var info = GetImageInfo(item, image, null);
 
-            await _libraryManager.UpdateImagesAsync(item).ConfigureAwait(false); // this makes sure dimensions and hashes are correct
-
-            foreach (var image in itemImages)
-            {
-                if (!item.AllowsMultipleImages(image.Type))
+                if (info is not null)
                 {
-                    var info = GetImageInfo(item, image, null);
-
-                    if (info is not null)
-                    {
-                        list.Add(info);
-                    }
+                    list.Add(info);
                 }
             }
+        }
 
-            foreach (var imageType in itemImages.Select(i => i.Type).Distinct().Where(item.AllowsMultipleImages))
-            {
-                var index = 0;
-
-                // Prevent implicitly captured closure
-                var currentImageType = imageType;
+        foreach (var imageType in itemImages.Select(i => i.Type).Distinct().Where(item.AllowsMultipleImages))
+        {
+            var index = 0;
 
-                foreach (var image in itemImages.Where(i => i.Type == currentImageType))
-                {
-                    var info = GetImageInfo(item, image, index);
+            // Prevent implicitly captured closure
+            var currentImageType = imageType;
 
-                    if (info is not null)
-                    {
-                        list.Add(info);
-                    }
+            foreach (var image in itemImages.Where(i => i.Type == currentImageType))
+            {
+                var info = GetImageInfo(item, image, index);
 
-                    index++;
+                if (info is not null)
+                {
+                    list.Add(info);
                 }
+
+                index++;
             }
+        }
 
-            return list;
+        return list;
+    }
+
+    /// <summary>
+    /// Gets the item's image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Items/{itemId}/Images/{imageType}")]
+    [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetItemImage(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery] string? tag,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets the item's image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Items/{itemId}/Images/{imageType}")]
-        [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetItemImage(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery] string? tag,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                itemId,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    itemId,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Gets the item's image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetItemImageByIndex(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute] int imageIndex,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery] string? tag,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets the item's image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetItemImageByIndex(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute] int imageIndex,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery] string? tag,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                itemId,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    itemId,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Gets the item's image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")]
+    [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}", Name = "HeadItemImage2")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetItemImage2(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int maxWidth,
+        [FromRoute, Required] int maxHeight,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromRoute, Required] string tag,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromRoute, Required] ImageFormat format,
+        [FromRoute, Required] double percentPlayed,
+        [FromRoute, Required] int unplayedCount,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromRoute, Required] int imageIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets the item's image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")]
-        [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}", Name = "HeadItemImage2")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetItemImage2(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int maxWidth,
-            [FromRoute, Required] int maxHeight,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromRoute, Required] string tag,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromRoute, Required] ImageFormat format,
-            [FromRoute, Required] double percentPlayed,
-            [FromRoute, Required] int unplayedCount,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromRoute, Required] int imageIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                itemId,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    itemId,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get artist image by name.
+    /// </summary>
+    /// <param name="name">Artist name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetArtistImage(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromRoute, Required] int imageIndex)
+    {
+        var item = _libraryManager.GetArtist(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get artist image by name.
-        /// </summary>
-        /// <param name="name">Artist name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetArtistImage(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromRoute, Required] int imageIndex)
-        {
-            var item = _libraryManager.GetArtist(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get genre image by name.
+    /// </summary>
+    /// <param name="name">Genre name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Genres/{name}/Images/{imageType}")]
+    [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetGenreImage(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetGenre(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get genre image by name.
-        /// </summary>
-        /// <param name="name">Genre name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Genres/{name}/Images/{imageType}")]
-        [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetGenreImage(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetGenre(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get genre image by name.
+    /// </summary>
+    /// <param name="name">Genre name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetGenreImageByIndex(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var item = _libraryManager.GetGenre(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get genre image by name.
-        /// </summary>
-        /// <param name="name">Genre name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetGenreImageByIndex(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var item = _libraryManager.GetGenre(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get music genre image by name.
+    /// </summary>
+    /// <param name="name">Music genre name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("MusicGenres/{name}/Images/{imageType}")]
+    [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetMusicGenreImage(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetMusicGenre(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get music genre image by name.
-        /// </summary>
-        /// <param name="name">Music genre name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("MusicGenres/{name}/Images/{imageType}")]
-        [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetMusicGenreImage(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetMusicGenre(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get music genre image by name.
+    /// </summary>
+    /// <param name="name">Music genre name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetMusicGenreImageByIndex(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var item = _libraryManager.GetMusicGenre(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get music genre image by name.
-        /// </summary>
-        /// <param name="name">Music genre name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetMusicGenreImageByIndex(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var item = _libraryManager.GetMusicGenre(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get person image by name.
+    /// </summary>
+    /// <param name="name">Person name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Persons/{name}/Images/{imageType}")]
+    [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetPersonImage(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetPerson(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get person image by name.
-        /// </summary>
-        /// <param name="name">Person name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Persons/{name}/Images/{imageType}")]
-        [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetPersonImage(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetPerson(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get person image by name.
+    /// </summary>
+    /// <param name="name">Person name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetPersonImageByIndex(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var item = _libraryManager.GetPerson(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get person image by name.
-        /// </summary>
-        /// <param name="name">Person name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetPersonImageByIndex(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var item = _libraryManager.GetPerson(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get studio image by name.
+    /// </summary>
+    /// <param name="name">Studio name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Studios/{name}/Images/{imageType}")]
+    [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetStudioImage(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetStudio(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get studio image by name.
-        /// </summary>
-        /// <param name="name">Studio name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Studios/{name}/Images/{imageType}")]
-        [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetStudioImage(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetStudio(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get studio image by name.
+    /// </summary>
+    /// <param name="name">Studio name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetStudioImageByIndex(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var item = _libraryManager.GetStudio(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get studio image by name.
-        /// </summary>
-        /// <param name="name">Studio name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetStudioImageByIndex(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var item = _libraryManager.GetStudio(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get user profile image.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Users/{userId}/Images/{imageType}")]
+    [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetUserImage(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var user = _userManager.GetUserById(userId);
+        if (user?.ProfileImage is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get user profile image.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Users/{userId}/Images/{imageType}")]
-        [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetUserImage(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var user = _userManager.GetUserById(userId);
-            if (user?.ProfileImage is null)
-            {
-                return NotFound();
-            }
+        var info = new ItemImageInfo
+        {
+            Path = user.ProfileImage.Path,
+            Type = ImageType.Profile,
+            DateModified = user.ProfileImage.LastModified
+        };
 
-            var info = new ItemImageInfo
-            {
-                Path = user.ProfileImage.Path,
-                Type = ImageType.Profile,
-                DateModified = user.ProfileImage.LastModified
-            };
+        if (width.HasValue)
+        {
+            info.Width = width.Value;
+        }
 
-            if (width.HasValue)
-            {
-                info.Width = width.Value;
-            }
+        if (height.HasValue)
+        {
+            info.Height = height.Value;
+        }
 
-            if (height.HasValue)
-            {
-                info.Height = height.Value;
-            }
+        return await GetImageInternal(
+                user.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                null,
+                info)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    user.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    null,
-                    info)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get user profile image.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetUserImageByIndex(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var user = _userManager.GetUserById(userId);
+        if (user?.ProfileImage is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get user profile image.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetUserImageByIndex(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var user = _userManager.GetUserById(userId);
-            if (user?.ProfileImage is null)
-            {
-                return NotFound();
-            }
+        var info = new ItemImageInfo
+        {
+            Path = user.ProfileImage.Path,
+            Type = ImageType.Profile,
+            DateModified = user.ProfileImage.LastModified
+        };
 
-            var info = new ItemImageInfo
-            {
-                Path = user.ProfileImage.Path,
-                Type = ImageType.Profile,
-                DateModified = user.ProfileImage.LastModified
-            };
+        if (width.HasValue)
+        {
+            info.Width = width.Value;
+        }
 
-            if (width.HasValue)
-            {
-                info.Width = width.Value;
-            }
+        if (height.HasValue)
+        {
+            info.Height = height.Value;
+        }
 
-            if (height.HasValue)
-            {
-                info.Height = height.Value;
-            }
+        return await GetImageInternal(
+                user.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                null,
+                info)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    user.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    null,
-                    info)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Generates or gets the splashscreen.
+    /// </summary>
+    /// <param name="tag">Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="blur">Blur image.</param>
+    /// <param name="backgroundColor">Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Apply a foreground layer on top of the image.</param>
+    /// <param name="quality">Quality setting, from 0-100.</param>
+    /// <response code="200">Splashscreen returned successfully.</response>
+    /// <returns>The splashscreen.</returns>
+    [HttpGet("Branding/Splashscreen")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetSplashscreen(
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery, Range(0, 100)] int quality = 90)
+    {
+        var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+        if (!brandingOptions.SplashscreenEnabled)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Generates or gets the splashscreen.
-        /// </summary>
-        /// <param name="tag">Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="blur">Blur image.</param>
-        /// <param name="backgroundColor">Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Apply a foreground layer on top of the image.</param>
-        /// <param name="quality">Quality setting, from 0-100.</param>
-        /// <response code="200">Splashscreen returned successfully.</response>
-        /// <returns>The splashscreen.</returns>
-        [HttpGet("Branding/Splashscreen")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetSplashscreen(
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery, Range(0, 100)] int quality = 90)
+        string splashscreenPath;
+
+        if (!string.IsNullOrWhiteSpace(brandingOptions.SplashscreenLocation)
+            && System.IO.File.Exists(brandingOptions.SplashscreenLocation))
         {
-            var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
-            if (!brandingOptions.SplashscreenEnabled)
+            splashscreenPath = brandingOptions.SplashscreenLocation;
+        }
+        else
+        {
+            splashscreenPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
+            if (!System.IO.File.Exists(splashscreenPath))
             {
                 return NotFound();
             }
+        }
+
+        var outputFormats = GetOutputFormats(format);
+
+        TimeSpan? cacheDuration = null;
+        if (!string.IsNullOrEmpty(tag))
+        {
+            cacheDuration = TimeSpan.FromDays(365);
+        }
+
+        var options = new ImageProcessingOptions
+        {
+            Image = new ItemImageInfo
+            {
+                Path = splashscreenPath
+            },
+            Height = height,
+            MaxHeight = maxHeight,
+            MaxWidth = maxWidth,
+            FillHeight = fillHeight,
+            FillWidth = fillWidth,
+            Quality = quality,
+            Width = width,
+            Blur = blur,
+            BackgroundColor = backgroundColor,
+            ForegroundLayer = foregroundLayer,
+            SupportedOutputFormats = outputFormats
+        };
+
+        return await GetImageResult(
+                options,
+                cacheDuration,
+                ImmutableDictionary<string, string>.Empty)
+            .ConfigureAwait(false);
+    }
 
-            string splashscreenPath;
+    /// <summary>
+    /// Uploads a custom splashscreen.
+    /// The body is expected to the image contents base64 encoded.
+    /// </summary>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    /// <response code="204">Successfully uploaded new splashscreen.</response>
+    /// <response code="400">Error reading MimeType from uploaded image.</response>
+    /// <response code="403">User does not have permission to upload splashscreen..</response>
+    /// <exception cref="ArgumentException">Error reading the image format.</exception>
+    [HttpPost("Branding/Splashscreen")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [AcceptsImageFile]
+    public async Task<ActionResult> UploadCustomSplashscreen()
+    {
+        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+        await using (memoryStream.ConfigureAwait(false))
+        {
+            var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType;
 
-            if (!string.IsNullOrWhiteSpace(brandingOptions.SplashscreenLocation)
-                && System.IO.File.Exists(brandingOptions.SplashscreenLocation))
+            if (!mimeType.HasValue)
             {
-                splashscreenPath = brandingOptions.SplashscreenLocation;
+                return BadRequest("Error reading mimetype from uploaded image");
             }
-            else
+
+            var extension = MimeTypes.ToExtension(mimeType.Value);
+            if (string.IsNullOrEmpty(extension))
             {
-                splashscreenPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
-                if (!System.IO.File.Exists(splashscreenPath))
-                {
-                    return NotFound();
-                }
+                return BadRequest("Error converting mimetype to an image extension");
             }
 
-            var outputFormats = GetOutputFormats(format);
+            var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension);
+            var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+            brandingOptions.SplashscreenLocation = filePath;
+            _serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
 
-            TimeSpan? cacheDuration = null;
-            if (!string.IsNullOrEmpty(tag))
+            var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
+            await using (fs.ConfigureAwait(false))
             {
-                cacheDuration = TimeSpan.FromDays(365);
+                await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false);
             }
 
-            var options = new ImageProcessingOptions
-            {
-                Image = new ItemImageInfo
-                {
-                    Path = splashscreenPath
-                },
-                Height = height,
-                MaxHeight = maxHeight,
-                MaxWidth = maxWidth,
-                FillHeight = fillHeight,
-                FillWidth = fillWidth,
-                Quality = quality,
-                Width = width,
-                Blur = blur,
-                BackgroundColor = backgroundColor,
-                ForegroundLayer = foregroundLayer,
-                SupportedOutputFormats = outputFormats
-            };
+            return NoContent();
+        }
+    }
 
-            return await GetImageResult(
-                    options,
-                    cacheDuration,
-                    ImmutableDictionary<string, string>.Empty)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Delete a custom splashscreen.
+    /// </summary>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    /// <response code="204">Successfully deleted the custom splashscreen.</response>
+    /// <response code="403">User does not have permission to delete splashscreen..</response>
+    [HttpDelete("Branding/Splashscreen")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult DeleteCustomSplashscreen()
+    {
+        var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+        if (!string.IsNullOrEmpty(brandingOptions.SplashscreenLocation)
+            && System.IO.File.Exists(brandingOptions.SplashscreenLocation))
+        {
+            System.IO.File.Delete(brandingOptions.SplashscreenLocation);
+            brandingOptions.SplashscreenLocation = null;
+            _serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
         }
 
-        /// <summary>
-        /// Uploads a custom splashscreen.
-        /// The body is expected to the image contents base64 encoded.
-        /// </summary>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        /// <response code="204">Successfully uploaded new splashscreen.</response>
-        /// <response code="400">Error reading MimeType from uploaded image.</response>
-        /// <response code="403">User does not have permission to upload splashscreen..</response>
-        /// <exception cref="ArgumentException">Error reading the image format.</exception>
-        [HttpPost("Branding/Splashscreen")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status400BadRequest)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        [AcceptsImageFile]
-        public async Task<ActionResult> UploadCustomSplashscreen()
-        {
-            var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-            await using (memoryStream.ConfigureAwait(false))
-            {
-                var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType;
+        return NoContent();
+    }
 
-                if (!mimeType.HasValue)
-                {
-                    return BadRequest("Error reading mimetype from uploaded image");
-                }
+    private static async Task<MemoryStream> GetMemoryStream(Stream inputStream)
+    {
+        using var reader = new StreamReader(inputStream);
+        var text = await reader.ReadToEndAsync().ConfigureAwait(false);
 
-                var extension = MimeTypes.ToExtension(mimeType.Value);
-                if (string.IsNullOrEmpty(extension))
-                {
-                    return BadRequest("Error converting mimetype to an image extension");
-                }
+        var bytes = Convert.FromBase64String(text);
+        return new MemoryStream(bytes, 0, bytes.Length, false, true);
+    }
 
-                var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension);
-                var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
-                brandingOptions.SplashscreenLocation = filePath;
-                _serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
+    private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex)
+    {
+        int? width = null;
+        int? height = null;
+        string? blurhash = null;
+        long length = 0;
+
+        try
+        {
+            if (info.IsLocalFile)
+            {
+                var fileInfo = _fileSystem.GetFileInfo(info.Path);
+                length = fileInfo.Length;
 
-                var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
-                await using (fs.ConfigureAwait(false))
+                blurhash = info.BlurHash;
+                width = info.Width;
+                height = info.Height;
+
+                if (width <= 0 || height <= 0)
                 {
-                    await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false);
+                    width = null;
+                    height = null;
                 }
-
-                return NoContent();
             }
         }
-
-        /// <summary>
-        /// Delete a custom splashscreen.
-        /// </summary>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        /// <response code="204">Successfully deleted the custom splashscreen.</response>
-        /// <response code="403">User does not have permission to delete splashscreen..</response>
-        [HttpDelete("Branding/Splashscreen")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult DeleteCustomSplashscreen()
+        catch (Exception ex)
         {
-            var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
-            if (!string.IsNullOrEmpty(brandingOptions.SplashscreenLocation)
-                && System.IO.File.Exists(brandingOptions.SplashscreenLocation))
-            {
-                System.IO.File.Delete(brandingOptions.SplashscreenLocation);
-                brandingOptions.SplashscreenLocation = null;
-                _serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
-            }
-
-            return NoContent();
+            _logger.LogError(ex, "Error getting image information for {Item}", item.Name);
         }
 
-        private static async Task<MemoryStream> GetMemoryStream(Stream inputStream)
+        try
         {
-            using var reader = new StreamReader(inputStream);
-            var text = await reader.ReadToEndAsync().ConfigureAwait(false);
-
-            var bytes = Convert.FromBase64String(text);
-            return new MemoryStream(bytes, 0, bytes.Length, false, true);
+            return new ImageInfo
+            {
+                Path = info.Path,
+                ImageIndex = imageIndex,
+                ImageType = info.Type,
+                ImageTag = _imageProcessor.GetImageCacheTag(item, info),
+                Size = length,
+                BlurHash = blurhash,
+                Width = width,
+                Height = height
+            };
         }
-
-        private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex)
+        catch (Exception ex)
         {
-            int? width = null;
-            int? height = null;
-            string? blurhash = null;
-            long length = 0;
-
-            try
-            {
-                if (info.IsLocalFile)
-                {
-                    var fileInfo = _fileSystem.GetFileInfo(info.Path);
-                    length = fileInfo.Length;
-
-                    blurhash = info.BlurHash;
-                    width = info.Width;
-                    height = info.Height;
-
-                    if (width <= 0 || height <= 0)
-                    {
-                        width = null;
-                        height = null;
-                    }
-                }
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error getting image information for {Item}", item.Name);
-            }
+            _logger.LogError(ex, "Error getting image information for {Path}", info.Path);
+            return null;
+        }
+    }
 
-            try
+    private async Task<ActionResult> GetImageInternal(
+        Guid itemId,
+        ImageType imageType,
+        int? imageIndex,
+        string? tag,
+        ImageFormat? format,
+        int? maxWidth,
+        int? maxHeight,
+        double? percentPlayed,
+        int? unplayedCount,
+        int? width,
+        int? height,
+        int? quality,
+        int? fillWidth,
+        int? fillHeight,
+        int? blur,
+        string? backgroundColor,
+        string? foregroundLayer,
+        BaseItem? item,
+        ItemImageInfo? imageInfo = null)
+    {
+        if (percentPlayed.HasValue)
+        {
+            if (percentPlayed.Value <= 0)
             {
-                return new ImageInfo
-                {
-                    Path = info.Path,
-                    ImageIndex = imageIndex,
-                    ImageType = info.Type,
-                    ImageTag = _imageProcessor.GetImageCacheTag(item, info),
-                    Size = length,
-                    BlurHash = blurhash,
-                    Width = width,
-                    Height = height
-                };
+                percentPlayed = null;
             }
-            catch (Exception ex)
+            else if (percentPlayed.Value >= 100)
             {
-                _logger.LogError(ex, "Error getting image information for {Path}", info.Path);
-                return null;
+                percentPlayed = null;
             }
         }
 
-        private async Task<ActionResult> GetImageInternal(
-            Guid itemId,
-            ImageType imageType,
-            int? imageIndex,
-            string? tag,
-            ImageFormat? format,
-            int? maxWidth,
-            int? maxHeight,
-            double? percentPlayed,
-            int? unplayedCount,
-            int? width,
-            int? height,
-            int? quality,
-            int? fillWidth,
-            int? fillHeight,
-            int? blur,
-            string? backgroundColor,
-            string? foregroundLayer,
-            BaseItem? item,
-            ItemImageInfo? imageInfo = null)
-        {
-            if (percentPlayed.HasValue)
-            {
-                if (percentPlayed.Value <= 0)
-                {
-                    percentPlayed = null;
-                }
-                else if (percentPlayed.Value >= 100)
-                {
-                    percentPlayed = null;
-                }
-            }
-
-            if (percentPlayed.HasValue)
-            {
-                unplayedCount = null;
-            }
+        if (percentPlayed.HasValue)
+        {
+            unplayedCount = null;
+        }
 
-            if (unplayedCount.HasValue
-                && unplayedCount.Value <= 0)
-            {
-                unplayedCount = null;
-            }
+        if (unplayedCount.HasValue
+            && unplayedCount.Value <= 0)
+        {
+            unplayedCount = null;
+        }
 
+        if (imageInfo is null)
+        {
+            imageInfo = item?.GetImageInfo(imageType, imageIndex ?? 0);
             if (imageInfo is null)
             {
-                imageInfo = item?.GetImageInfo(imageType, imageIndex ?? 0);
-                if (imageInfo is null)
-                {
-                    return NotFound(string.Format(NumberFormatInfo.InvariantInfo, "{0} does not have an image of type {1}", item?.Name, imageType));
-                }
+                return NotFound(string.Format(NumberFormatInfo.InvariantInfo, "{0} does not have an image of type {1}", item?.Name, imageType));
             }
+        }
 
-            var outputFormats = GetOutputFormats(format);
+        var outputFormats = GetOutputFormats(format);
 
-            TimeSpan? cacheDuration = null;
+        TimeSpan? cacheDuration = null;
 
-            if (!string.IsNullOrEmpty(tag))
-            {
-                cacheDuration = TimeSpan.FromDays(365);
-            }
+        if (!string.IsNullOrEmpty(tag))
+        {
+            cacheDuration = TimeSpan.FromDays(365);
+        }
 
-            var responseHeaders = new Dictionary<string, string>
+        var responseHeaders = new Dictionary<string, string>
             {
                 { "transferMode.dlna.org", "Interactive" },
                 { "realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*" }
             };
 
-            if (!imageInfo.IsLocalFile && item is not null)
-            {
-                imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, imageIndex ?? 0).ConfigureAwait(false);
-            }
-
-            var options = new ImageProcessingOptions
-            {
-                Height = height,
-                ImageIndex = imageIndex ?? 0,
-                Image = imageInfo,
-                Item = item,
-                ItemId = itemId,
-                MaxHeight = maxHeight,
-                MaxWidth = maxWidth,
-                FillHeight = fillHeight,
-                FillWidth = fillWidth,
-                Quality = quality ?? 100,
-                Width = width,
-                PercentPlayed = percentPlayed ?? 0,
-                UnplayedCount = unplayedCount,
-                Blur = blur,
-                BackgroundColor = backgroundColor,
-                ForegroundLayer = foregroundLayer,
-                SupportedOutputFormats = outputFormats
-            };
-
-            return await GetImageResult(
-                options,
-                cacheDuration,
-                responseHeaders).ConfigureAwait(false);
+        if (!imageInfo.IsLocalFile && item is not null)
+        {
+            imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, imageIndex ?? 0).ConfigureAwait(false);
         }
 
-        private ImageFormat[] GetOutputFormats(ImageFormat? format)
+        var options = new ImageProcessingOptions
         {
-            if (format.HasValue)
-            {
-                return new[] { format.Value };
-            }
+            Height = height,
+            ImageIndex = imageIndex ?? 0,
+            Image = imageInfo,
+            Item = item,
+            ItemId = itemId,
+            MaxHeight = maxHeight,
+            MaxWidth = maxWidth,
+            FillHeight = fillHeight,
+            FillWidth = fillWidth,
+            Quality = quality ?? 100,
+            Width = width,
+            PercentPlayed = percentPlayed ?? 0,
+            UnplayedCount = unplayedCount,
+            Blur = blur,
+            BackgroundColor = backgroundColor,
+            ForegroundLayer = foregroundLayer,
+            SupportedOutputFormats = outputFormats
+        };
+
+        return await GetImageResult(
+            options,
+            cacheDuration,
+            responseHeaders).ConfigureAwait(false);
+    }
 
-            return GetClientSupportedFormats();
+    private ImageFormat[] GetOutputFormats(ImageFormat? format)
+    {
+        if (format.HasValue)
+        {
+            return new[] { format.Value };
         }
 
-        private ImageFormat[] GetClientSupportedFormats()
+        return GetClientSupportedFormats();
+    }
+
+    private ImageFormat[] GetClientSupportedFormats()
+    {
+        var supportedFormats = Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept);
+        for (var i = 0; i < supportedFormats.Length; i++)
         {
-            var supportedFormats = Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept);
-            for (var i = 0; i < supportedFormats.Length; i++)
+            // Remove charsets etc. (anything after semi-colon)
+            var type = supportedFormats[i];
+            int index = type.IndexOf(';', StringComparison.Ordinal);
+            if (index != -1)
             {
-                // Remove charsets etc. (anything after semi-colon)
-                var type = supportedFormats[i];
-                int index = type.IndexOf(';', StringComparison.Ordinal);
-                if (index != -1)
-                {
-                    supportedFormats[i] = type.Substring(0, index);
-                }
+                supportedFormats[i] = type.Substring(0, index);
             }
+        }
 
-            var acceptParam = Request.Query[HeaderNames.Accept];
+        var acceptParam = Request.Query[HeaderNames.Accept];
 
-            var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false);
+        var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false);
 
-            if (!supportsWebP)
+        if (!supportsWebP)
+        {
+            var userAgent = Request.Headers[HeaderNames.UserAgent].ToString();
+            if (userAgent.Contains("crosswalk", StringComparison.OrdinalIgnoreCase)
+                && userAgent.Contains("android", StringComparison.OrdinalIgnoreCase))
             {
-                var userAgent = Request.Headers[HeaderNames.UserAgent].ToString();
-                if (userAgent.Contains("crosswalk", StringComparison.OrdinalIgnoreCase)
-                    && userAgent.Contains("android", StringComparison.OrdinalIgnoreCase))
-                {
-                    supportsWebP = true;
-                }
+                supportsWebP = true;
             }
+        }
 
-            var formats = new List<ImageFormat>(4);
+        var formats = new List<ImageFormat>(4);
 
-            if (supportsWebP)
-            {
-                formats.Add(ImageFormat.Webp);
-            }
+        if (supportsWebP)
+        {
+            formats.Add(ImageFormat.Webp);
+        }
 
-            formats.Add(ImageFormat.Jpg);
-            formats.Add(ImageFormat.Png);
+        formats.Add(ImageFormat.Jpg);
+        formats.Add(ImageFormat.Png);
 
-            if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true))
-            {
-                formats.Add(ImageFormat.Gif);
-            }
+        if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true))
+        {
+            formats.Add(ImageFormat.Gif);
+        }
 
-            return formats.ToArray();
+        return formats.ToArray();
+    }
+
+    private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll)
+    {
+        if (requestAcceptTypes.Contains(format.GetMimeType()))
+        {
+            return true;
         }
 
-        private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll)
+        if (acceptAll && requestAcceptTypes.Contains("*/*"))
         {
-            if (requestAcceptTypes.Contains(format.GetMimeType()))
-            {
-                return true;
-            }
+            return true;
+        }
 
-            if (acceptAll && requestAcceptTypes.Contains("*/*"))
-            {
-                return true;
-            }
+        // Review if this should be jpeg, jpg or both for ImageFormat.Jpg
+        var normalized = format.ToString().ToLowerInvariant();
+        return string.Equals(acceptParam, normalized, StringComparison.OrdinalIgnoreCase);
+    }
 
-            // Review if this should be jpeg, jpg or both for ImageFormat.Jpg
-            var normalized = format.ToString().ToLowerInvariant();
-            return string.Equals(acceptParam, normalized, StringComparison.OrdinalIgnoreCase);
+    private async Task<ActionResult> GetImageResult(
+        ImageProcessingOptions imageProcessingOptions,
+        TimeSpan? cacheDuration,
+        IDictionary<string, string> headers)
+    {
+        var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(imageProcessingOptions).ConfigureAwait(false);
+
+        var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache");
+        var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader);
+
+        // if the parsing of the IfModifiedSince header was not successful, disable caching
+        if (!parsingSuccessful)
+        {
+            // disableCaching = true;
         }
 
-        private async Task<ActionResult> GetImageResult(
-            ImageProcessingOptions imageProcessingOptions,
-            TimeSpan? cacheDuration,
-            IDictionary<string, string> headers)
+        foreach (var (key, value) in headers)
         {
-            var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(imageProcessingOptions).ConfigureAwait(false);
+            Response.Headers.Add(key, value);
+        }
 
-            var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache");
-            var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader);
+        Response.ContentType = imageContentType ?? MediaTypeNames.Text.Plain;
+        Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture));
+        Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept);
 
-            // if the parsing of the IfModifiedSince header was not successful, disable caching
-            if (!parsingSuccessful)
+        if (disableCaching)
+        {
+            Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate");
+            Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate");
+        }
+        else
+        {
+            if (cacheDuration.HasValue)
             {
-                // disableCaching = true;
+                Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds);
             }
-
-            foreach (var (key, value) in headers)
+            else
             {
-                Response.Headers.Add(key, value);
+                Response.Headers.Add(HeaderNames.CacheControl, "public");
             }
 
-            Response.ContentType = imageContentType ?? MediaTypeNames.Text.Plain;
-            Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture));
-            Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept);
+            Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture));
 
-            if (disableCaching)
+            // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified
+            if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue)
             {
-                Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate");
-                Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate");
-            }
-            else
-            {
-                if (cacheDuration.HasValue)
-                {
-                    Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds);
-                }
-                else
+                if (ifModifiedSinceHeader.Add(cacheDuration.Value) < DateTime.UtcNow)
                 {
-                    Response.Headers.Add(HeaderNames.CacheControl, "public");
-                }
-
-                Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture));
-
-                // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified
-                if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue)
-                {
-                    if (ifModifiedSinceHeader.Add(cacheDuration.Value) < DateTime.UtcNow)
-                    {
-                        Response.StatusCode = StatusCodes.Status304NotModified;
-                        return new ContentResult();
-                    }
+                    Response.StatusCode = StatusCodes.Status304NotModified;
+                    return new ContentResult();
                 }
             }
-
-            return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain);
         }
+
+        return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain);
     }
 }

+ 322 - 323
Jellyfin.Api/Controllers/InstantMixController.cs

@@ -16,346 +16,345 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The instant mix controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class InstantMixController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly IDtoService _dtoService;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IMusicManager _musicManager;
+
     /// <summary>
-    /// The instant mix controller.
+    /// Initializes a new instance of the <see cref="InstantMixController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class InstantMixController : BaseJellyfinApiController
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    public InstantMixController(
+        IUserManager userManager,
+        IDtoService dtoService,
+        IMusicManager musicManager,
+        ILibraryManager libraryManager)
     {
-        private readonly IUserManager _userManager;
-        private readonly IDtoService _dtoService;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IMusicManager _musicManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="InstantMixController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        public InstantMixController(
-            IUserManager userManager,
-            IDtoService dtoService,
-            IMusicManager musicManager,
-            ILibraryManager libraryManager)
-        {
-            _userManager = userManager;
-            _dtoService = dtoService;
-            _musicManager = musicManager;
-            _libraryManager = libraryManager;
-        }
+        _userManager = userManager;
+        _dtoService = dtoService;
+        _musicManager = musicManager;
+        _libraryManager = libraryManager;
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given song.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Songs/{id}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong(
-            [FromRoute, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var item = _libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given song.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Songs/{id}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong(
+        [FromRoute, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var item = _libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given album.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Albums/{id}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum(
-            [FromRoute, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var album = _libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given album.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Albums/{id}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum(
+        [FromRoute, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var album = _libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given playlist.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Playlists/{id}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist(
-            [FromRoute, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var playlist = (Playlist)_libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given playlist.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Playlists/{id}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist(
+        [FromRoute, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var playlist = (Playlist)_libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given genre.
-        /// </summary>
-        /// <param name="name">The genre name.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("MusicGenres/{name}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName(
-            [FromRoute, Required] string name,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given genre.
+    /// </summary>
+    /// <param name="name">The genre name.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("MusicGenres/{name}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName(
+        [FromRoute, Required] string name,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given artist.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Artists/{id}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
-            [FromRoute, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var item = _libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given artist.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Artists/{id}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
+        [FromRoute, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var item = _libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given item.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Items/{id}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem(
-            [FromRoute, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var item = _libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given item.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Items/{id}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem(
+        [FromRoute, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var item = _libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given artist.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Artists/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Obsolete("Use GetInstantMixFromArtists")]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2(
-            [FromQuery, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            return GetInstantMixFromArtists(
-                id,
-                userId,
-                limit,
-                fields,
-                enableImages,
-                enableUserData,
-                imageTypeLimit,
-                enableImageTypes);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given artist.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Artists/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Use GetInstantMixFromArtists")]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2(
+        [FromQuery, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        return GetInstantMixFromArtists(
+            id,
+            userId,
+            limit,
+            fields,
+            enableImages,
+            enableUserData,
+            imageTypeLimit,
+            enableImageTypes);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given genre.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("MusicGenres/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById(
-            [FromQuery, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var item = _libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given genre.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("MusicGenres/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById(
+        [FromQuery, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var item = _libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions)
-        {
-            var list = items;
+    private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions)
+    {
+        var list = items;
 
-            var totalCount = list.Count;
+        var totalCount = list.Count;
 
-            if (limit.HasValue && limit < list.Count)
-            {
-                list = list.GetRange(0, limit.Value);
-            }
+        if (limit.HasValue && limit < list.Count)
+        {
+            list = list.GetRange(0, limit.Value);
+        }
 
-            var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);
+        var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);
 
-            var result = new QueryResult<BaseItemDto>(
-                0,
-                totalCount,
-                returnList);
+        var result = new QueryResult<BaseItemDto>(
+            0,
+            totalCount,
+            returnList);
 
-            return result;
-        }
+        return result;
     }
 }

+ 231 - 232
Jellyfin.Api/Controllers/ItemLookupController.cs

@@ -18,257 +18,256 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Item lookup controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class ItemLookupController : BaseJellyfinApiController
 {
+    private readonly IProviderManager _providerManager;
+    private readonly IFileSystem _fileSystem;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILogger<ItemLookupController> _logger;
+
     /// <summary>
-    /// Item lookup controller.
+    /// Initializes a new instance of the <see cref="ItemLookupController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class ItemLookupController : BaseJellyfinApiController
+    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param>
+    public ItemLookupController(
+        IProviderManager providerManager,
+        IFileSystem fileSystem,
+        ILibraryManager libraryManager,
+        ILogger<ItemLookupController> logger)
     {
-        private readonly IProviderManager _providerManager;
-        private readonly IFileSystem _fileSystem;
-        private readonly ILibraryManager _libraryManager;
-        private readonly ILogger<ItemLookupController> _logger;
+        _providerManager = providerManager;
+        _fileSystem = fileSystem;
+        _libraryManager = libraryManager;
+        _logger = logger;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ItemLookupController"/> class.
-        /// </summary>
-        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param>
-        public ItemLookupController(
-            IProviderManager providerManager,
-            IFileSystem fileSystem,
-            ILibraryManager libraryManager,
-            ILogger<ItemLookupController> logger)
+    /// <summary>
+    /// Get the item's external id info.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">External id info retrieved.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>List of external id info.</returns>
+    [HttpGet("Items/{itemId}/ExternalIdInfos")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
         {
-            _providerManager = providerManager;
-            _fileSystem = fileSystem;
-            _libraryManager = libraryManager;
-            _logger = logger;
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get the item's external id info.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">External id info retrieved.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>List of external id info.</returns>
-        [HttpGet("Items/{itemId}/ExternalIdInfos")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
-
-            return Ok(_providerManager.GetExternalIdInfos(item));
-        }
+        return Ok(_providerManager.GetExternalIdInfos(item));
+    }
 
-        /// <summary>
-        /// Get movie remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Movie remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Movie")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MovieInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get movie remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Movie remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Movie")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MovieInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get trailer remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Trailer remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Trailer")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery<TrailerInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get trailer remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Trailer remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Trailer")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery<TrailerInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get music video remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Music video remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/MusicVideo")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MusicVideoInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get music video remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Music video remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/MusicVideo")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MusicVideoInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get series remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Series remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Series")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery<SeriesInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get series remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Series remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Series")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery<SeriesInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get box set remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Box set remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/BoxSet")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BoxSetInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get box set remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Box set remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/BoxSet")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BoxSetInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get music artist remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Music artist remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/MusicArtist")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery<ArtistInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get music artist remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Music artist remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/MusicArtist")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery<ArtistInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get music album remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Music album remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/MusicAlbum")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery<AlbumInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get music album remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Music album remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/MusicAlbum")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery<AlbumInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get person remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Person remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Person")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery<PersonLookupInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get person remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Person remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Person")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery<PersonLookupInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get book remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Book remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Book")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BookInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get book remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Book remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Book")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BookInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Applies search criteria to an item and refreshes metadata.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="searchResult">The remote search result.</param>
-        /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param>
-        /// <response code="204">Item metadata refreshed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="NoContentResult"/>.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Apply/{itemId}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> ApplySearchCriteria(
-            [FromRoute, Required] Guid itemId,
-            [FromBody, Required] RemoteSearchResult searchResult,
-            [FromQuery] bool replaceAllImages = true)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            _logger.LogInformation(
-                "Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}",
-                item.Id,
-                item.Name,
-                searchResult.ProviderIds);
+    /// <summary>
+    /// Applies search criteria to an item and refreshes metadata.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="searchResult">The remote search result.</param>
+    /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param>
+    /// <response code="204">Item metadata refreshed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="NoContentResult"/>.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Apply/{itemId}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> ApplySearchCriteria(
+        [FromRoute, Required] Guid itemId,
+        [FromBody, Required] RemoteSearchResult searchResult,
+        [FromQuery] bool replaceAllImages = true)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        _logger.LogInformation(
+            "Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}",
+            item.Id,
+            item.Name,
+            searchResult.ProviderIds);
 
-            // Since the refresh process won't erase provider Ids, we need to set this explicitly now.
-            item.ProviderIds = searchResult.ProviderIds;
-            await _providerManager.RefreshFullItem(
-                item,
-                new MetadataRefreshOptions(new DirectoryService(_fileSystem))
-                {
-                    MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
-                    ImageRefreshMode = MetadataRefreshMode.FullRefresh,
-                    ReplaceAllMetadata = true,
-                    ReplaceAllImages = replaceAllImages,
-                    SearchResult = searchResult,
-                    RemoveOldMetadata = true
-                },
-                CancellationToken.None).ConfigureAwait(false);
+        // Since the refresh process won't erase provider Ids, we need to set this explicitly now.
+        item.ProviderIds = searchResult.ProviderIds;
+        await _providerManager.RefreshFullItem(
+            item,
+            new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+            {
+                MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+                ImageRefreshMode = MetadataRefreshMode.FullRefresh,
+                ReplaceAllMetadata = true,
+                ReplaceAllImages = replaceAllImages,
+                SearchResult = searchResult,
+                RemoveOldMetadata = true
+            },
+            CancellationToken.None).ConfigureAwait(false);
 
-            return NoContent();
-        }
+        return NoContent();
     }
 }

+ 62 - 63
Jellyfin.Api/Controllers/ItemRefreshController.cs

@@ -9,78 +9,77 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Item Refresh Controller.
+/// </summary>
+[Route("Items")]
+[Authorize(Policy = Policies.RequiresElevation)]
+public class ItemRefreshController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IProviderManager _providerManager;
+    private readonly IFileSystem _fileSystem;
+
     /// <summary>
-    /// Item Refresh Controller.
+    /// Initializes a new instance of the <see cref="ItemRefreshController"/> class.
     /// </summary>
-    [Route("Items")]
-    [Authorize(Policy = Policies.RequiresElevation)]
-    public class ItemRefreshController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param>
+    /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
+    public ItemRefreshController(
+        ILibraryManager libraryManager,
+        IProviderManager providerManager,
+        IFileSystem fileSystem)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IProviderManager _providerManager;
-        private readonly IFileSystem _fileSystem;
+        _libraryManager = libraryManager;
+        _providerManager = providerManager;
+        _fileSystem = fileSystem;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ItemRefreshController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param>
-        /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
-        public ItemRefreshController(
-            ILibraryManager libraryManager,
-            IProviderManager providerManager,
-            IFileSystem fileSystem)
+    /// <summary>
+    /// Refreshes metadata for an item.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param>
+    /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param>
+    /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param>
+    /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param>
+    /// <response code="204">Item metadata refresh queued.</response>
+    /// <response code="404">Item to refresh not found.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+    [HttpPost("{itemId}/Refresh")]
+    [Description("Refreshes metadata for an item.")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult RefreshItem(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None,
+        [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None,
+        [FromQuery] bool replaceAllMetadata = false,
+        [FromQuery] bool replaceAllImages = false)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
         {
-            _libraryManager = libraryManager;
-            _providerManager = providerManager;
-            _fileSystem = fileSystem;
+            return NotFound();
         }
 
-        /// <summary>
-        /// Refreshes metadata for an item.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param>
-        /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param>
-        /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param>
-        /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param>
-        /// <response code="204">Item metadata refresh queued.</response>
-        /// <response code="404">Item to refresh not found.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
-        [HttpPost("{itemId}/Refresh")]
-        [Description("Refreshes metadata for an item.")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult RefreshItem(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None,
-            [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None,
-            [FromQuery] bool replaceAllMetadata = false,
-            [FromQuery] bool replaceAllImages = false)
+        var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
         {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+            MetadataRefreshMode = metadataRefreshMode,
+            ImageRefreshMode = imageRefreshMode,
+            ReplaceAllImages = replaceAllImages,
+            ReplaceAllMetadata = replaceAllMetadata,
+            ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh
+                || imageRefreshMode == MetadataRefreshMode.FullRefresh
+                || replaceAllImages
+                || replaceAllMetadata,
+            IsAutomated = false
+        };
 
-            var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
-            {
-                MetadataRefreshMode = metadataRefreshMode,
-                ImageRefreshMode = imageRefreshMode,
-                ReplaceAllImages = replaceAllImages,
-                ReplaceAllMetadata = replaceAllMetadata,
-                ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh
-                    || imageRefreshMode == MetadataRefreshMode.FullRefresh
-                    || replaceAllImages
-                    || replaceAllMetadata,
-                IsAutomated = false
-            };
-
-            _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High);
-            return NoContent();
-        }
+        _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High);
+        return NoContent();
     }
 }

+ 332 - 333
Jellyfin.Api/Controllers/ItemUpdateController.cs

@@ -20,332 +20,332 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Item update controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.RequiresElevation)]
+public class ItemUpdateController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IProviderManager _providerManager;
+    private readonly ILocalizationManager _localizationManager;
+    private readonly IFileSystem _fileSystem;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ItemUpdateController"/> class.
+    /// </summary>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+    /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    public ItemUpdateController(
+        IFileSystem fileSystem,
+        ILibraryManager libraryManager,
+        IProviderManager providerManager,
+        ILocalizationManager localizationManager,
+        IServerConfigurationManager serverConfigurationManager)
+    {
+        _libraryManager = libraryManager;
+        _providerManager = providerManager;
+        _localizationManager = localizationManager;
+        _fileSystem = fileSystem;
+        _serverConfigurationManager = serverConfigurationManager;
+    }
+
     /// <summary>
-    /// Item update controller.
+    /// Updates an item.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.RequiresElevation)]
-    public class ItemUpdateController : BaseJellyfinApiController
+    /// <param name="itemId">The item id.</param>
+    /// <param name="request">The new item properties.</param>
+    /// <response code="204">Item updated.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+    [HttpPost("Items/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IProviderManager _providerManager;
-        private readonly ILocalizationManager _localizationManager;
-        private readonly IFileSystem _fileSystem;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ItemUpdateController"/> class.
-        /// </summary>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
-        /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        public ItemUpdateController(
-            IFileSystem fileSystem,
-            ILibraryManager libraryManager,
-            IProviderManager providerManager,
-            ILocalizationManager localizationManager,
-            IServerConfigurationManager serverConfigurationManager)
-        {
-            _libraryManager = libraryManager;
-            _providerManager = providerManager;
-            _localizationManager = localizationManager;
-            _fileSystem = fileSystem;
-            _serverConfigurationManager = serverConfigurationManager;
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Updates an item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="request">The new item properties.</param>
-        /// <response code="204">Item updated.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
-        [HttpPost("Items/{itemId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        var newLockData = request.LockData ?? false;
+        var isLockedChanged = item.IsLocked != newLockData;
 
-            var newLockData = request.LockData ?? false;
-            var isLockedChanged = item.IsLocked != newLockData;
+        var series = item as Series;
+        var displayOrderChanged = series is not null && !string.Equals(
+            series.DisplayOrder ?? string.Empty,
+            request.DisplayOrder ?? string.Empty,
+            StringComparison.OrdinalIgnoreCase);
 
-            var series = item as Series;
-            var displayOrderChanged = series is not null && !string.Equals(
-                series.DisplayOrder ?? string.Empty,
-                request.DisplayOrder ?? string.Empty,
-                StringComparison.OrdinalIgnoreCase);
+        // Do this first so that metadata savers can pull the updates from the database.
+        if (request.People is not null)
+        {
+            _libraryManager.UpdatePeople(
+                item,
+                request.People.Select(x => new PersonInfo
+                {
+                    Name = x.Name,
+                    Role = x.Role,
+                    Type = x.Type
+                }).ToList());
+        }
 
-            // Do this first so that metadata savers can pull the updates from the database.
-            if (request.People is not null)
-            {
-                _libraryManager.UpdatePeople(
-                    item,
-                    request.People.Select(x => new PersonInfo
-                    {
-                        Name = x.Name,
-                        Role = x.Role,
-                        Type = x.Type
-                    }).ToList());
-            }
+        UpdateItem(request, item);
 
-            UpdateItem(request, item);
+        item.OnMetadataChanged();
 
-            item.OnMetadataChanged();
+        await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
 
-            await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+        if (isLockedChanged && item.IsFolder)
+        {
+            var folder = (Folder)item;
 
-            if (isLockedChanged && item.IsFolder)
+            foreach (var child in folder.GetRecursiveChildren())
             {
-                var folder = (Folder)item;
+                child.IsLocked = newLockData;
+                await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+            }
+        }
 
-                foreach (var child in folder.GetRecursiveChildren())
+        if (displayOrderChanged)
+        {
+            _providerManager.QueueRefresh(
+                series!.Id,
+                new MetadataRefreshOptions(new DirectoryService(_fileSystem))
                 {
-                    child.IsLocked = newLockData;
-                    await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
-                }
-            }
+                    MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+                    ImageRefreshMode = MetadataRefreshMode.FullRefresh,
+                    ReplaceAllMetadata = true
+                },
+                RefreshPriority.High);
+        }
 
-            if (displayOrderChanged)
-            {
-                _providerManager.QueueRefresh(
-                    series!.Id,
-                    new MetadataRefreshOptions(new DirectoryService(_fileSystem))
-                    {
-                        MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
-                        ImageRefreshMode = MetadataRefreshMode.FullRefresh,
-                        ReplaceAllMetadata = true
-                    },
-                    RefreshPriority.High);
-            }
+        return NoContent();
+    }
 
-            return NoContent();
-        }
+    /// <summary>
+    /// Gets metadata editor info for an item.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <response code="200">Item metadata editor returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+    [HttpGet("Items/{itemId}/MetadataEditor")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId)
+    {
+        var item = _libraryManager.GetItemById(itemId);
 
-        /// <summary>
-        /// Gets metadata editor info for an item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <response code="200">Item metadata editor returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
-        [HttpGet("Items/{itemId}/MetadataEditor")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-
-            var info = new MetadataEditorInfo
-            {
-                ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(),
-                ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(),
-                Countries = _localizationManager.GetCountries().ToArray(),
-                Cultures = _localizationManager.GetCultures().ToArray()
-            };
-
-            if (!item.IsVirtualItem
-                && item is not ICollectionFolder
-                && item is not UserView
-                && item is not AggregateFolder
-                && item is not LiveTvChannel
-                && item is not IItemByName
-                && item.SourceType == SourceType.Library)
+        var info = new MetadataEditorInfo
+        {
+            ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(),
+            ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(),
+            Countries = _localizationManager.GetCountries().ToArray(),
+            Cultures = _localizationManager.GetCultures().ToArray()
+        };
+
+        if (!item.IsVirtualItem
+            && item is not ICollectionFolder
+            && item is not UserView
+            && item is not AggregateFolder
+            && item is not LiveTvChannel
+            && item is not IItemByName
+            && item.SourceType == SourceType.Library)
+        {
+            var inheritedContentType = _libraryManager.GetInheritedContentType(item);
+            var configuredContentType = _libraryManager.GetConfiguredContentType(item);
+
+            if (string.IsNullOrWhiteSpace(inheritedContentType) ||
+                !string.IsNullOrWhiteSpace(configuredContentType))
             {
-                var inheritedContentType = _libraryManager.GetInheritedContentType(item);
-                var configuredContentType = _libraryManager.GetConfiguredContentType(item);
+                info.ContentTypeOptions = GetContentTypeOptions(true).ToArray();
+                info.ContentType = configuredContentType;
 
-                if (string.IsNullOrWhiteSpace(inheritedContentType) ||
-                    !string.IsNullOrWhiteSpace(configuredContentType))
+                if (string.IsNullOrWhiteSpace(inheritedContentType)
+                    || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
                 {
-                    info.ContentTypeOptions = GetContentTypeOptions(true).ToArray();
-                    info.ContentType = configuredContentType;
-
-                    if (string.IsNullOrWhiteSpace(inheritedContentType)
-                        || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
-                    {
-                        info.ContentTypeOptions = info.ContentTypeOptions
-                            .Where(i => string.IsNullOrWhiteSpace(i.Value)
-                                        || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
-                            .ToArray();
-                    }
+                    info.ContentTypeOptions = info.ContentTypeOptions
+                        .Where(i => string.IsNullOrWhiteSpace(i.Value)
+                                    || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
+                        .ToArray();
                 }
             }
-
-            return info;
         }
 
-        /// <summary>
-        /// Updates an item's content type.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="contentType">The content type of the item.</param>
-        /// <response code="204">Item content type updated.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
-        [HttpPost("Items/{itemId}/ContentType")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return info;
+    }
 
-            var path = item.ContainingFolderPath;
+    /// <summary>
+    /// Updates an item's content type.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="contentType">The content type of the item.</param>
+    /// <response code="204">Item content type updated.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+    [HttpPost("Items/{itemId}/ContentType")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
+        }
 
-            var types = _serverConfigurationManager.Configuration.ContentTypes
-                .Where(i => !string.IsNullOrWhiteSpace(i.Name))
-                .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase))
-                .ToList();
+        var path = item.ContainingFolderPath;
 
-            if (!string.IsNullOrWhiteSpace(contentType))
-            {
-                types.Add(new NameValuePair
-                {
-                    Name = path,
-                    Value = contentType
-                });
-            }
+        var types = _serverConfigurationManager.Configuration.ContentTypes
+            .Where(i => !string.IsNullOrWhiteSpace(i.Name))
+            .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase))
+            .ToList();
 
-            _serverConfigurationManager.Configuration.ContentTypes = types.ToArray();
-            _serverConfigurationManager.SaveConfiguration();
-            return NoContent();
+        if (!string.IsNullOrWhiteSpace(contentType))
+        {
+            types.Add(new NameValuePair
+            {
+                Name = path,
+                Value = contentType
+            });
         }
 
-        private void UpdateItem(BaseItemDto request, BaseItem item)
-        {
-            item.Name = request.Name;
-            item.ForcedSortName = request.ForcedSortName;
+        _serverConfigurationManager.Configuration.ContentTypes = types.ToArray();
+        _serverConfigurationManager.SaveConfiguration();
+        return NoContent();
+    }
 
-            item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle;
+    private void UpdateItem(BaseItemDto request, BaseItem item)
+    {
+        item.Name = request.Name;
+        item.ForcedSortName = request.ForcedSortName;
 
-            item.CriticRating = request.CriticRating;
+        item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle;
 
-            item.CommunityRating = request.CommunityRating;
-            item.IndexNumber = request.IndexNumber;
-            item.ParentIndexNumber = request.ParentIndexNumber;
-            item.Overview = request.Overview;
-            item.Genres = request.Genres;
+        item.CriticRating = request.CriticRating;
 
-            if (item is Episode episode)
-            {
-                episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber;
-                episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber;
-                episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber;
-            }
+        item.CommunityRating = request.CommunityRating;
+        item.IndexNumber = request.IndexNumber;
+        item.ParentIndexNumber = request.ParentIndexNumber;
+        item.Overview = request.Overview;
+        item.Genres = request.Genres;
 
-            item.Tags = request.Tags;
+        if (item is Episode episode)
+        {
+            episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber;
+            episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber;
+            episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber;
+        }
 
-            if (request.Taglines is not null)
-            {
-                item.Tagline = request.Taglines.FirstOrDefault();
-            }
+        item.Tags = request.Tags;
 
-            if (request.Studios is not null)
-            {
-                item.Studios = request.Studios.Select(x => x.Name).ToArray();
-            }
+        if (request.Taglines is not null)
+        {
+            item.Tagline = request.Taglines.FirstOrDefault();
+        }
 
-            if (request.DateCreated.HasValue)
-            {
-                item.DateCreated = NormalizeDateTime(request.DateCreated.Value);
-            }
+        if (request.Studios is not null)
+        {
+            item.Studios = request.Studios.Select(x => x.Name).ToArray();
+        }
 
-            item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null;
-            item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null;
-            item.ProductionYear = request.ProductionYear;
-            item.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating;
-            item.CustomRating = request.CustomRating;
+        if (request.DateCreated.HasValue)
+        {
+            item.DateCreated = NormalizeDateTime(request.DateCreated.Value);
+        }
 
-            if (request.ProductionLocations is not null)
-            {
-                item.ProductionLocations = request.ProductionLocations;
-            }
+        item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null;
+        item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null;
+        item.ProductionYear = request.ProductionYear;
+        item.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating;
+        item.CustomRating = request.CustomRating;
 
-            item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode;
-            item.PreferredMetadataLanguage = request.PreferredMetadataLanguage;
+        if (request.ProductionLocations is not null)
+        {
+            item.ProductionLocations = request.ProductionLocations;
+        }
 
-            if (item is IHasDisplayOrder hasDisplayOrder)
-            {
-                hasDisplayOrder.DisplayOrder = request.DisplayOrder;
-            }
+        item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode;
+        item.PreferredMetadataLanguage = request.PreferredMetadataLanguage;
 
-            if (item is IHasAspectRatio hasAspectRatio)
-            {
-                hasAspectRatio.AspectRatio = request.AspectRatio;
-            }
+        if (item is IHasDisplayOrder hasDisplayOrder)
+        {
+            hasDisplayOrder.DisplayOrder = request.DisplayOrder;
+        }
+
+        if (item is IHasAspectRatio hasAspectRatio)
+        {
+            hasAspectRatio.AspectRatio = request.AspectRatio;
+        }
 
-            item.IsLocked = request.LockData ?? false;
+        item.IsLocked = request.LockData ?? false;
 
-            if (request.LockedFields is not null)
-            {
-                item.LockedFields = request.LockedFields;
-            }
+        if (request.LockedFields is not null)
+        {
+            item.LockedFields = request.LockedFields;
+        }
 
-            // Only allow this for series. Runtimes for media comes from ffprobe.
-            if (item is Series)
-            {
-                item.RunTimeTicks = request.RunTimeTicks;
-            }
+        // Only allow this for series. Runtimes for media comes from ffprobe.
+        if (item is Series)
+        {
+            item.RunTimeTicks = request.RunTimeTicks;
+        }
 
-            foreach (var pair in request.ProviderIds.ToList())
+        foreach (var pair in request.ProviderIds.ToList())
+        {
+            if (string.IsNullOrEmpty(pair.Value))
             {
-                if (string.IsNullOrEmpty(pair.Value))
-                {
-                    request.ProviderIds.Remove(pair.Key);
-                }
+                request.ProviderIds.Remove(pair.Key);
             }
+        }
 
-            item.ProviderIds = request.ProviderIds;
+        item.ProviderIds = request.ProviderIds;
 
-            if (item is Video video)
-            {
-                video.Video3DFormat = request.Video3DFormat;
-            }
+        if (item is Video video)
+        {
+            video.Video3DFormat = request.Video3DFormat;
+        }
 
-            if (request.AlbumArtists is not null)
+        if (request.AlbumArtists is not null)
+        {
+            if (item is IHasAlbumArtist hasAlbumArtists)
             {
-                if (item is IHasAlbumArtist hasAlbumArtists)
-                {
-                    hasAlbumArtists.AlbumArtists = request
-                        .AlbumArtists
-                        .Select(i => i.Name)
-                        .ToArray();
-                }
+                hasAlbumArtists.AlbumArtists = request
+                    .AlbumArtists
+                    .Select(i => i.Name)
+                    .ToArray();
             }
+        }
 
-            if (request.ArtistItems is not null)
+        if (request.ArtistItems is not null)
+        {
+            if (item is IHasArtist hasArtists)
             {
-                if (item is IHasArtist hasArtists)
-                {
-                    hasArtists.Artists = request
-                        .ArtistItems
-                        .Select(i => i.Name)
-                        .ToArray();
-                }
+                hasArtists.Artists = request
+                    .ArtistItems
+                    .Select(i => i.Name)
+                    .ToArray();
             }
+        }
 
-            switch (item)
-            {
-                case Audio song:
-                    song.Album = request.Album;
-                    break;
-                case MusicVideo musicVideo:
-                    musicVideo.Album = request.Album;
-                    break;
-                case Series series:
+        switch (item)
+        {
+            case Audio song:
+                song.Album = request.Album;
+                break;
+            case MusicVideo musicVideo:
+                musicVideo.Album = request.Album;
+                break;
+            case Series series:
                 {
                     series.Status = GetSeriesStatus(request);
 
@@ -357,93 +357,92 @@ namespace Jellyfin.Api.Controllers
 
                     break;
                 }
-            }
         }
+    }
 
-        private SeriesStatus? GetSeriesStatus(BaseItemDto item)
+    private SeriesStatus? GetSeriesStatus(BaseItemDto item)
+    {
+        if (string.IsNullOrEmpty(item.Status))
         {
-            if (string.IsNullOrEmpty(item.Status))
-            {
-                return null;
-            }
-
-            return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true);
+            return null;
         }
 
-        private DateTime NormalizeDateTime(DateTime val)
-        {
-            return DateTime.SpecifyKind(val, DateTimeKind.Utc);
-        }
+        return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true);
+    }
 
-        private List<NameValuePair> GetContentTypeOptions(bool isForItem)
-        {
-            var list = new List<NameValuePair>();
+    private DateTime NormalizeDateTime(DateTime val)
+    {
+        return DateTime.SpecifyKind(val, DateTimeKind.Utc);
+    }
 
-            if (isForItem)
-            {
-                list.Add(new NameValuePair
-                {
-                    Name = "Inherit",
-                    Value = string.Empty
-                });
-            }
+    private List<NameValuePair> GetContentTypeOptions(bool isForItem)
+    {
+        var list = new List<NameValuePair>();
 
+        if (isForItem)
+        {
             list.Add(new NameValuePair
             {
-                Name = "Movies",
-                Value = "movies"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "Music",
-                Value = "music"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "Shows",
-                Value = "tvshows"
+                Name = "Inherit",
+                Value = string.Empty
             });
+        }
 
-            if (!isForItem)
-            {
-                list.Add(new NameValuePair
-                {
-                    Name = "Books",
-                    Value = "books"
-                });
-            }
+        list.Add(new NameValuePair
+        {
+            Name = "Movies",
+            Value = "movies"
+        });
+        list.Add(new NameValuePair
+        {
+            Name = "Music",
+            Value = "music"
+        });
+        list.Add(new NameValuePair
+        {
+            Name = "Shows",
+            Value = "tvshows"
+        });
 
+        if (!isForItem)
+        {
             list.Add(new NameValuePair
             {
-                Name = "HomeVideos",
-                Value = "homevideos"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "MusicVideos",
-                Value = "musicvideos"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "Photos",
-                Value = "photos"
+                Name = "Books",
+                Value = "books"
             });
+        }
 
-            if (!isForItem)
-            {
-                list.Add(new NameValuePair
-                {
-                    Name = "MixedContent",
-                    Value = string.Empty
-                });
-            }
+        list.Add(new NameValuePair
+        {
+            Name = "HomeVideos",
+            Value = "homevideos"
+        });
+        list.Add(new NameValuePair
+        {
+            Name = "MusicVideos",
+            Value = "musicvideos"
+        });
+        list.Add(new NameValuePair
+        {
+            Name = "Photos",
+            Value = "photos"
+        });
 
-            foreach (var val in list)
+        if (!isForItem)
+        {
+            list.Add(new NameValuePair
             {
-                val.Name = _localizationManager.GetLocalizedString(val.Name);
-            }
+                Name = "MixedContent",
+                Value = string.Empty
+            });
+        }
 
-            return list;
+        foreach (var val in list)
+        {
+            val.Name = _localizationManager.GetLocalizedString(val.Name);
         }
+
+        return list;
     }
 }

+ 793 - 794
Jellyfin.Api/Controllers/ItemsController.cs

@@ -20,854 +20,853 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The items controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class ItemsController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILocalizationManager _localization;
+    private readonly IDtoService _dtoService;
+    private readonly ILogger<ItemsController> _logger;
+    private readonly ISessionManager _sessionManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ItemsController"/> class.
+    /// </summary>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+    /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
+    public ItemsController(
+        IUserManager userManager,
+        ILibraryManager libraryManager,
+        ILocalizationManager localization,
+        IDtoService dtoService,
+        ILogger<ItemsController> logger,
+        ISessionManager sessionManager)
+    {
+        _userManager = userManager;
+        _libraryManager = libraryManager;
+        _localization = localization;
+        _dtoService = dtoService;
+        _logger = logger;
+        _sessionManager = sessionManager;
+    }
+
     /// <summary>
-    /// The items controller.
+    /// Gets items based on a query.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class ItemsController : BaseJellyfinApiController
+    /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param>
+    /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
+    /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
+    /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
+    /// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
+    /// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
+    /// <param name="hasTrailer">Optional filter by items with trailers.</param>
+    /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+    /// <param name="parentIndexNumber">Optional filter by parent index number.</param>
+    /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
+    /// <param name="isHd">Optional filter by items that are HD or not.</param>
+    /// <param name="is4K">Optional filter by items that are 4K or not.</param>
+    /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param>
+    /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param>
+    /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
+    /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
+    /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+    /// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
+    /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
+    /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
+    /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
+    /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
+    /// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
+    /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param>
+    /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param>
+    /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param>
+    /// <param name="isMovie">Optional filter for live tv movies.</param>
+    /// <param name="isSeries">Optional filter for live tv series.</param>
+    /// <param name="isNews">Optional filter for live tv news.</param>
+    /// <param name="isKids">Optional filter for live tv kids.</param>
+    /// <param name="isSports">Optional filter for live tv sports.</param>
+    /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
+    /// <param name="searchTerm">Optional. Filter based on a search term.</param>
+    /// <param name="sortOrder">Sort Order - Ascending, Descending.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+    /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+    /// <param name="isPlayed">Optional filter by items that are played, or not.</param>
+    /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+    /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+    /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+    /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
+    /// <param name="enableUserData">Optional, include user data.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+    /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
+    /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+    /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+    /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param>
+    /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param>
+    /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
+    /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
+    /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
+    /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param>
+    /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param>
+    /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
+    /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param>
+    /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
+    /// <param name="isLocked">Optional filter by items that are locked.</param>
+    /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
+    /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
+    /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
+    /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
+    /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
+    /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
+    /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
+    /// <param name="is3D">Optional filter by items that are 3D, or not.</param>
+    /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param>
+    /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+    /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+    /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+    /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+    /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
+    /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
+    [HttpGet("Items")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetItems(
+        [FromQuery] Guid? userId,
+        [FromQuery] string? maxOfficialRating,
+        [FromQuery] bool? hasThemeSong,
+        [FromQuery] bool? hasThemeVideo,
+        [FromQuery] bool? hasSubtitles,
+        [FromQuery] bool? hasSpecialFeature,
+        [FromQuery] bool? hasTrailer,
+        [FromQuery] Guid? adjacentTo,
+        [FromQuery] int? parentIndexNumber,
+        [FromQuery] bool? hasParentalRating,
+        [FromQuery] bool? isHd,
+        [FromQuery] bool? is4K,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
+        [FromQuery] bool? isMissing,
+        [FromQuery] bool? isUnaired,
+        [FromQuery] double? minCommunityRating,
+        [FromQuery] double? minCriticRating,
+        [FromQuery] DateTime? minPremiereDate,
+        [FromQuery] DateTime? minDateLastSaved,
+        [FromQuery] DateTime? minDateLastSavedForUser,
+        [FromQuery] DateTime? maxPremiereDate,
+        [FromQuery] bool? hasOverview,
+        [FromQuery] bool? hasImdbId,
+        [FromQuery] bool? hasTmdbId,
+        [FromQuery] bool? hasTvdbId,
+        [FromQuery] bool? isMovie,
+        [FromQuery] bool? isSeries,
+        [FromQuery] bool? isNews,
+        [FromQuery] bool? isKids,
+        [FromQuery] bool? isSports,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] bool? recursive,
+        [FromQuery] string? searchTerm,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery] bool? isFavorite,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery] bool? isPlayed,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] string? person,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
+        [FromQuery] string? minOfficialRating,
+        [FromQuery] bool? isLocked,
+        [FromQuery] bool? isPlaceHolder,
+        [FromQuery] bool? hasOfficialRating,
+        [FromQuery] bool? collapseBoxSetItems,
+        [FromQuery] int? minWidth,
+        [FromQuery] int? minHeight,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] bool? is3D,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
+        [FromQuery] string? nameStartsWithOrGreater,
+        [FromQuery] string? nameStartsWith,
+        [FromQuery] string? nameLessThan,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery] bool enableTotalRecordCount = true,
+        [FromQuery] bool? enableImages = true)
     {
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly ILocalizationManager _localization;
-        private readonly IDtoService _dtoService;
-        private readonly ILogger<ItemsController> _logger;
-        private readonly ISessionManager _sessionManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ItemsController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
-        /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
-        public ItemsController(
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            ILocalizationManager localization,
-            IDtoService dtoService,
-            ILogger<ItemsController> logger,
-            ISessionManager sessionManager)
+        var isApiKey = User.GetIsApiKey();
+        // if api key is used (auth.IsApiKey == true), then `user` will be null throughout this method
+        var user = !isApiKey && userId.HasValue && !userId.Value.Equals(default)
+            ? _userManager.GetUserById(userId.Value)
+            : null;
+
+        // beyond this point, we're either using an api key or we have a valid user
+        if (!isApiKey && user is null)
         {
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _localization = localization;
-            _dtoService = dtoService;
-            _logger = logger;
-            _sessionManager = sessionManager;
+            return BadRequest("userId is required");
         }
 
-        /// <summary>
-        /// Gets items based on a query.
-        /// </summary>
-        /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param>
-        /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
-        /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
-        /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
-        /// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
-        /// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
-        /// <param name="hasTrailer">Optional filter by items with trailers.</param>
-        /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
-        /// <param name="parentIndexNumber">Optional filter by parent index number.</param>
-        /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
-        /// <param name="isHd">Optional filter by items that are HD or not.</param>
-        /// <param name="is4K">Optional filter by items that are 4K or not.</param>
-        /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param>
-        /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param>
-        /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
-        /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
-        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
-        /// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
-        /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
-        /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
-        /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
-        /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
-        /// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
-        /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param>
-        /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param>
-        /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param>
-        /// <param name="isMovie">Optional filter for live tv movies.</param>
-        /// <param name="isSeries">Optional filter for live tv series.</param>
-        /// <param name="isNews">Optional filter for live tv news.</param>
-        /// <param name="isKids">Optional filter for live tv kids.</param>
-        /// <param name="isSports">Optional filter for live tv sports.</param>
-        /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
-        /// <param name="searchTerm">Optional. Filter based on a search term.</param>
-        /// <param name="sortOrder">Sort Order - Ascending, Descending.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
-        /// <param name="isPlayed">Optional filter by items that are played, or not.</param>
-        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
-        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
-        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
-        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
-        /// <param name="enableUserData">Optional, include user data.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
-        /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param>
-        /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param>
-        /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
-        /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
-        /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
-        /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param>
-        /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param>
-        /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
-        /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param>
-        /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
-        /// <param name="isLocked">Optional filter by items that are locked.</param>
-        /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
-        /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
-        /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
-        /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
-        /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
-        /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
-        /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
-        /// <param name="is3D">Optional filter by items that are 3D, or not.</param>
-        /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param>
-        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
-        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
-        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
-        /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
-        [HttpGet("Items")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetItems(
-            [FromQuery] Guid? userId,
-            [FromQuery] string? maxOfficialRating,
-            [FromQuery] bool? hasThemeSong,
-            [FromQuery] bool? hasThemeVideo,
-            [FromQuery] bool? hasSubtitles,
-            [FromQuery] bool? hasSpecialFeature,
-            [FromQuery] bool? hasTrailer,
-            [FromQuery] Guid? adjacentTo,
-            [FromQuery] int? parentIndexNumber,
-            [FromQuery] bool? hasParentalRating,
-            [FromQuery] bool? isHd,
-            [FromQuery] bool? is4K,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
-            [FromQuery] bool? isMissing,
-            [FromQuery] bool? isUnaired,
-            [FromQuery] double? minCommunityRating,
-            [FromQuery] double? minCriticRating,
-            [FromQuery] DateTime? minPremiereDate,
-            [FromQuery] DateTime? minDateLastSaved,
-            [FromQuery] DateTime? minDateLastSavedForUser,
-            [FromQuery] DateTime? maxPremiereDate,
-            [FromQuery] bool? hasOverview,
-            [FromQuery] bool? hasImdbId,
-            [FromQuery] bool? hasTmdbId,
-            [FromQuery] bool? hasTvdbId,
-            [FromQuery] bool? isMovie,
-            [FromQuery] bool? isSeries,
-            [FromQuery] bool? isNews,
-            [FromQuery] bool? isKids,
-            [FromQuery] bool? isSports,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] bool? recursive,
-            [FromQuery] string? searchTerm,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
-            [FromQuery] bool? isFavorite,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery] bool? isPlayed,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
-            [FromQuery] string? minOfficialRating,
-            [FromQuery] bool? isLocked,
-            [FromQuery] bool? isPlaceHolder,
-            [FromQuery] bool? hasOfficialRating,
-            [FromQuery] bool? collapseBoxSetItems,
-            [FromQuery] int? minWidth,
-            [FromQuery] int? minHeight,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] bool? is3D,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
-            [FromQuery] bool enableTotalRecordCount = true,
-            [FromQuery] bool? enableImages = true)
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+        if (includeItemTypes.Length == 1
+            && (includeItemTypes[0] == BaseItemKind.Playlist
+                || includeItemTypes[0] == BaseItemKind.BoxSet))
         {
-            var isApiKey = User.GetIsApiKey();
-            // if api key is used (auth.IsApiKey == true), then `user` will be null throughout this method
-            var user = !isApiKey && userId.HasValue && !userId.Value.Equals(default)
-                ? _userManager.GetUserById(userId.Value)
-                : null;
-
-            // beyond this point, we're either using an api key or we have a valid user
-            if (!isApiKey && user is null)
-            {
-                return BadRequest("userId is required");
-            }
+            parentId = null;
+        }
 
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var item = _libraryManager.GetParentItem(parentId, userId);
+        QueryResult<BaseItem> result;
 
-            if (includeItemTypes.Length == 1
-                && (includeItemTypes[0] == BaseItemKind.Playlist
-                    || includeItemTypes[0] == BaseItemKind.BoxSet))
+        if (item is not Folder folder)
+        {
+            folder = _libraryManager.GetUserRootFolder();
+        }
+
+        string? collectionType = null;
+        if (folder is IHasCollectionType hasCollectionType)
+        {
+            collectionType = hasCollectionType.CollectionType;
+        }
+
+        if (string.Equals(collectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
+        {
+            recursive = true;
+            includeItemTypes = new[] { BaseItemKind.Playlist };
+        }
+
+        if (item is not UserRootFolder
+            // api keys can always access all folders
+            && !isApiKey
+            // check the item is visible for the user
+            && !item.IsVisible(user))
+        {
+            _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}", user!.Username, item.Name);
+            return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
+        }
+
+        if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder)
+        {
+            var query = new InternalItemsQuery(user)
             {
-                parentId = null;
+                IsPlayed = isPlayed,
+                MediaTypes = mediaTypes,
+                IncludeItemTypes = includeItemTypes,
+                ExcludeItemTypes = excludeItemTypes,
+                Recursive = recursive ?? false,
+                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
+                IsFavorite = isFavorite,
+                Limit = limit,
+                StartIndex = startIndex,
+                IsMissing = isMissing,
+                IsUnaired = isUnaired,
+                CollapseBoxSetItems = collapseBoxSetItems,
+                NameLessThan = nameLessThan,
+                NameStartsWith = nameStartsWith,
+                NameStartsWithOrGreater = nameStartsWithOrGreater,
+                HasImdbId = hasImdbId,
+                IsPlaceHolder = isPlaceHolder,
+                IsLocked = isLocked,
+                MinWidth = minWidth,
+                MinHeight = minHeight,
+                MaxWidth = maxWidth,
+                MaxHeight = maxHeight,
+                Is3D = is3D,
+                HasTvdbId = hasTvdbId,
+                HasTmdbId = hasTmdbId,
+                IsMovie = isMovie,
+                IsSeries = isSeries,
+                IsNews = isNews,
+                IsKids = isKids,
+                IsSports = isSports,
+                HasOverview = hasOverview,
+                HasOfficialRating = hasOfficialRating,
+                HasParentalRating = hasParentalRating,
+                HasSpecialFeature = hasSpecialFeature,
+                HasSubtitles = hasSubtitles,
+                HasThemeSong = hasThemeSong,
+                HasThemeVideo = hasThemeVideo,
+                HasTrailer = hasTrailer,
+                IsHD = isHd,
+                Is4K = is4K,
+                Tags = tags,
+                OfficialRatings = officialRatings,
+                Genres = genres,
+                ArtistIds = artistIds,
+                AlbumArtistIds = albumArtistIds,
+                ContributingArtistIds = contributingArtistIds,
+                GenreIds = genreIds,
+                StudioIds = studioIds,
+                Person = person,
+                PersonIds = personIds,
+                PersonTypes = personTypes,
+                Years = years,
+                ImageTypes = imageTypes,
+                VideoTypes = videoTypes,
+                AdjacentTo = adjacentTo,
+                ItemIds = ids,
+                MinCommunityRating = minCommunityRating,
+                MinCriticRating = minCriticRating,
+                ParentId = parentId ?? Guid.Empty,
+                ParentIndexNumber = parentIndexNumber,
+                EnableTotalRecordCount = enableTotalRecordCount,
+                ExcludeItemIds = excludeItemIds,
+                DtoOptions = dtoOptions,
+                SearchTerm = searchTerm,
+                MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
+                MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
+                MinPremiereDate = minPremiereDate?.ToUniversalTime(),
+                MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
+            };
+
+            if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
+            {
+                query.CollapseBoxSetItems = false;
             }
 
-            var item = _libraryManager.GetParentItem(parentId, userId);
-            QueryResult<BaseItem> result;
+            foreach (var filter in filters)
+            {
+                switch (filter)
+                {
+                    case ItemFilter.Dislikes:
+                        query.IsLiked = false;
+                        break;
+                    case ItemFilter.IsFavorite:
+                        query.IsFavorite = true;
+                        break;
+                    case ItemFilter.IsFavoriteOrLikes:
+                        query.IsFavoriteOrLiked = true;
+                        break;
+                    case ItemFilter.IsFolder:
+                        query.IsFolder = true;
+                        break;
+                    case ItemFilter.IsNotFolder:
+                        query.IsFolder = false;
+                        break;
+                    case ItemFilter.IsPlayed:
+                        query.IsPlayed = true;
+                        break;
+                    case ItemFilter.IsResumable:
+                        query.IsResumable = true;
+                        break;
+                    case ItemFilter.IsUnplayed:
+                        query.IsPlayed = false;
+                        break;
+                    case ItemFilter.Likes:
+                        query.IsLiked = true;
+                        break;
+                }
+            }
 
-            if (item is not Folder folder)
+            // Filter by Series Status
+            if (seriesStatus.Length != 0)
             {
-                folder = _libraryManager.GetUserRootFolder();
+                query.SeriesStatuses = seriesStatus;
             }
 
-            string? collectionType = null;
-            if (folder is IHasCollectionType hasCollectionType)
+            // ExcludeLocationTypes
+            if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
             {
-                collectionType = hasCollectionType.CollectionType;
+                query.IsVirtualItem = false;
             }
 
-            if (string.Equals(collectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
+            if (locationTypes.Length > 0 && locationTypes.Length < 4)
             {
-                recursive = true;
-                includeItemTypes = new[] { BaseItemKind.Playlist };
+                query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
             }
 
-            if (item is not UserRootFolder
-                // api keys can always access all folders
-                && !isApiKey
-                // check the item is visible for the user
-                && !item.IsVisible(user))
+            // Min official rating
+            if (!string.IsNullOrWhiteSpace(minOfficialRating))
             {
-                _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}", user!.Username, item.Name);
-                return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
+                query.MinParentalRating = _localization.GetRatingLevel(minOfficialRating);
             }
 
-            if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder)
+            // Max official rating
+            if (!string.IsNullOrWhiteSpace(maxOfficialRating))
             {
-                var query = new InternalItemsQuery(user)
-                {
-                    IsPlayed = isPlayed,
-                    MediaTypes = mediaTypes,
-                    IncludeItemTypes = includeItemTypes,
-                    ExcludeItemTypes = excludeItemTypes,
-                    Recursive = recursive ?? false,
-                    OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
-                    IsFavorite = isFavorite,
-                    Limit = limit,
-                    StartIndex = startIndex,
-                    IsMissing = isMissing,
-                    IsUnaired = isUnaired,
-                    CollapseBoxSetItems = collapseBoxSetItems,
-                    NameLessThan = nameLessThan,
-                    NameStartsWith = nameStartsWith,
-                    NameStartsWithOrGreater = nameStartsWithOrGreater,
-                    HasImdbId = hasImdbId,
-                    IsPlaceHolder = isPlaceHolder,
-                    IsLocked = isLocked,
-                    MinWidth = minWidth,
-                    MinHeight = minHeight,
-                    MaxWidth = maxWidth,
-                    MaxHeight = maxHeight,
-                    Is3D = is3D,
-                    HasTvdbId = hasTvdbId,
-                    HasTmdbId = hasTmdbId,
-                    IsMovie = isMovie,
-                    IsSeries = isSeries,
-                    IsNews = isNews,
-                    IsKids = isKids,
-                    IsSports = isSports,
-                    HasOverview = hasOverview,
-                    HasOfficialRating = hasOfficialRating,
-                    HasParentalRating = hasParentalRating,
-                    HasSpecialFeature = hasSpecialFeature,
-                    HasSubtitles = hasSubtitles,
-                    HasThemeSong = hasThemeSong,
-                    HasThemeVideo = hasThemeVideo,
-                    HasTrailer = hasTrailer,
-                    IsHD = isHd,
-                    Is4K = is4K,
-                    Tags = tags,
-                    OfficialRatings = officialRatings,
-                    Genres = genres,
-                    ArtistIds = artistIds,
-                    AlbumArtistIds = albumArtistIds,
-                    ContributingArtistIds = contributingArtistIds,
-                    GenreIds = genreIds,
-                    StudioIds = studioIds,
-                    Person = person,
-                    PersonIds = personIds,
-                    PersonTypes = personTypes,
-                    Years = years,
-                    ImageTypes = imageTypes,
-                    VideoTypes = videoTypes,
-                    AdjacentTo = adjacentTo,
-                    ItemIds = ids,
-                    MinCommunityRating = minCommunityRating,
-                    MinCriticRating = minCriticRating,
-                    ParentId = parentId ?? Guid.Empty,
-                    ParentIndexNumber = parentIndexNumber,
-                    EnableTotalRecordCount = enableTotalRecordCount,
-                    ExcludeItemIds = excludeItemIds,
-                    DtoOptions = dtoOptions,
-                    SearchTerm = searchTerm,
-                    MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
-                    MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
-                    MinPremiereDate = minPremiereDate?.ToUniversalTime(),
-                    MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
-                };
-
-                if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
-                {
-                    query.CollapseBoxSetItems = false;
-                }
+                query.MaxParentalRating = _localization.GetRatingLevel(maxOfficialRating);
+            }
 
-                foreach (var filter in filters)
+            // Artists
+            if (artists.Length != 0)
+            {
+                query.ArtistIds = artists.Select(i =>
                 {
-                    switch (filter)
+                    try
                     {
-                        case ItemFilter.Dislikes:
-                            query.IsLiked = false;
-                            break;
-                        case ItemFilter.IsFavorite:
-                            query.IsFavorite = true;
-                            break;
-                        case ItemFilter.IsFavoriteOrLikes:
-                            query.IsFavoriteOrLiked = true;
-                            break;
-                        case ItemFilter.IsFolder:
-                            query.IsFolder = true;
-                            break;
-                        case ItemFilter.IsNotFolder:
-                            query.IsFolder = false;
-                            break;
-                        case ItemFilter.IsPlayed:
-                            query.IsPlayed = true;
-                            break;
-                        case ItemFilter.IsResumable:
-                            query.IsResumable = true;
-                            break;
-                        case ItemFilter.IsUnplayed:
-                            query.IsPlayed = false;
-                            break;
-                        case ItemFilter.Likes:
-                            query.IsLiked = true;
-                            break;
+                        return _libraryManager.GetArtist(i, new DtoOptions(false));
                     }
-                }
-
-                // Filter by Series Status
-                if (seriesStatus.Length != 0)
-                {
-                    query.SeriesStatuses = seriesStatus;
-                }
-
-                // ExcludeLocationTypes
-                if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
-                {
-                    query.IsVirtualItem = false;
-                }
-
-                if (locationTypes.Length > 0 && locationTypes.Length < 4)
-                {
-                    query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
-                }
-
-                // Min official rating
-                if (!string.IsNullOrWhiteSpace(minOfficialRating))
-                {
-                    query.MinParentalRating = _localization.GetRatingLevel(minOfficialRating);
-                }
-
-                // Max official rating
-                if (!string.IsNullOrWhiteSpace(maxOfficialRating))
-                {
-                    query.MaxParentalRating = _localization.GetRatingLevel(maxOfficialRating);
-                }
-
-                // Artists
-                if (artists.Length != 0)
-                {
-                    query.ArtistIds = artists.Select(i =>
+                    catch
                     {
-                        try
-                        {
-                            return _libraryManager.GetArtist(i, new DtoOptions(false));
-                        }
-                        catch
-                        {
-                            return null;
-                        }
-                    }).Where(i => i is not null).Select(i => i!.Id).ToArray();
-                }
+                        return null;
+                    }
+                }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+            }
 
-                // ExcludeArtistIds
-                if (excludeArtistIds.Length != 0)
-                {
-                    query.ExcludeArtistIds = excludeArtistIds;
-                }
+            // ExcludeArtistIds
+            if (excludeArtistIds.Length != 0)
+            {
+                query.ExcludeArtistIds = excludeArtistIds;
+            }
 
-                if (albumIds.Length != 0)
-                {
-                    query.AlbumIds = albumIds;
-                }
+            if (albumIds.Length != 0)
+            {
+                query.AlbumIds = albumIds;
+            }
 
-                // Albums
-                if (albums.Length != 0)
+            // Albums
+            if (albums.Length != 0)
+            {
+                query.AlbumIds = albums.SelectMany(i =>
                 {
-                    query.AlbumIds = albums.SelectMany(i =>
-                    {
-                        return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 });
-                    }).ToArray();
-                }
+                    return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 });
+                }).ToArray();
+            }
 
-                // Studios
-                if (studios.Length != 0)
+            // Studios
+            if (studios.Length != 0)
+            {
+                query.StudioIds = studios.Select(i =>
                 {
-                    query.StudioIds = studios.Select(i =>
+                    try
                     {
-                        try
-                        {
-                            return _libraryManager.GetStudio(i);
-                        }
-                        catch
-                        {
-                            return null;
-                        }
-                    }).Where(i => i is not null).Select(i => i!.Id).ToArray();
-                }
-
-                // Apply default sorting if none requested
-                if (query.OrderBy.Count == 0)
-                {
-                    // Albums by artist
-                    if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
+                        return _libraryManager.GetStudio(i);
+                    }
+                    catch
                     {
-                        query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) };
+                        return null;
                     }
-                }
-
-                result = folder.GetItems(query);
+                }).Where(i => i is not null).Select(i => i!.Id).ToArray();
             }
-            else
+
+            // Apply default sorting if none requested
+            if (query.OrderBy.Count == 0)
             {
-                var itemsArray = folder.GetChildren(user, true);
-                result = new QueryResult<BaseItem>(itemsArray);
+                // Albums by artist
+                if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
+                {
+                    query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) };
+                }
             }
 
-            return new QueryResult<BaseItemDto>(
-                startIndex,
-                result.TotalRecordCount,
-                _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user));
+            result = folder.GetItems(query);
         }
-
-        /// <summary>
-        /// Gets items based on a query.
-        /// </summary>
-        /// <param name="userId">The user id supplied as query parameter.</param>
-        /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
-        /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
-        /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
-        /// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
-        /// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
-        /// <param name="hasTrailer">Optional filter by items with trailers.</param>
-        /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
-        /// <param name="parentIndexNumber">Optional filter by parent index number.</param>
-        /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
-        /// <param name="isHd">Optional filter by items that are HD or not.</param>
-        /// <param name="is4K">Optional filter by items that are 4K or not.</param>
-        /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param>
-        /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param>
-        /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
-        /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
-        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
-        /// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
-        /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
-        /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
-        /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
-        /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
-        /// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
-        /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param>
-        /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param>
-        /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param>
-        /// <param name="isMovie">Optional filter for live tv movies.</param>
-        /// <param name="isSeries">Optional filter for live tv series.</param>
-        /// <param name="isNews">Optional filter for live tv news.</param>
-        /// <param name="isKids">Optional filter for live tv kids.</param>
-        /// <param name="isSports">Optional filter for live tv sports.</param>
-        /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
-        /// <param name="searchTerm">Optional. Filter based on a search term.</param>
-        /// <param name="sortOrder">Sort Order - Ascending, Descending.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
-        /// <param name="isPlayed">Optional filter by items that are played, or not.</param>
-        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
-        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
-        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
-        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
-        /// <param name="enableUserData">Optional, include user data.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
-        /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param>
-        /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param>
-        /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
-        /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
-        /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
-        /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param>
-        /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param>
-        /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
-        /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param>
-        /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
-        /// <param name="isLocked">Optional filter by items that are locked.</param>
-        /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
-        /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
-        /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
-        /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
-        /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
-        /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
-        /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
-        /// <param name="is3D">Optional filter by items that are 3D, or not.</param>
-        /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param>
-        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
-        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
-        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
-        /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
-        [HttpGet("Users/{userId}/Items")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId(
-            [FromRoute] Guid userId,
-            [FromQuery] string? maxOfficialRating,
-            [FromQuery] bool? hasThemeSong,
-            [FromQuery] bool? hasThemeVideo,
-            [FromQuery] bool? hasSubtitles,
-            [FromQuery] bool? hasSpecialFeature,
-            [FromQuery] bool? hasTrailer,
-            [FromQuery] Guid? adjacentTo,
-            [FromQuery] int? parentIndexNumber,
-            [FromQuery] bool? hasParentalRating,
-            [FromQuery] bool? isHd,
-            [FromQuery] bool? is4K,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
-            [FromQuery] bool? isMissing,
-            [FromQuery] bool? isUnaired,
-            [FromQuery] double? minCommunityRating,
-            [FromQuery] double? minCriticRating,
-            [FromQuery] DateTime? minPremiereDate,
-            [FromQuery] DateTime? minDateLastSaved,
-            [FromQuery] DateTime? minDateLastSavedForUser,
-            [FromQuery] DateTime? maxPremiereDate,
-            [FromQuery] bool? hasOverview,
-            [FromQuery] bool? hasImdbId,
-            [FromQuery] bool? hasTmdbId,
-            [FromQuery] bool? hasTvdbId,
-            [FromQuery] bool? isMovie,
-            [FromQuery] bool? isSeries,
-            [FromQuery] bool? isNews,
-            [FromQuery] bool? isKids,
-            [FromQuery] bool? isSports,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] bool? recursive,
-            [FromQuery] string? searchTerm,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
-            [FromQuery] bool? isFavorite,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery] bool? isPlayed,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
-            [FromQuery] string? minOfficialRating,
-            [FromQuery] bool? isLocked,
-            [FromQuery] bool? isPlaceHolder,
-            [FromQuery] bool? hasOfficialRating,
-            [FromQuery] bool? collapseBoxSetItems,
-            [FromQuery] int? minWidth,
-            [FromQuery] int? minHeight,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] bool? is3D,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
-            [FromQuery] bool enableTotalRecordCount = true,
-            [FromQuery] bool? enableImages = true)
+        else
         {
-            return GetItems(
-                userId,
-                maxOfficialRating,
-                hasThemeSong,
-                hasThemeVideo,
-                hasSubtitles,
-                hasSpecialFeature,
-                hasTrailer,
-                adjacentTo,
-                parentIndexNumber,
-                hasParentalRating,
-                isHd,
-                is4K,
-                locationTypes,
-                excludeLocationTypes,
-                isMissing,
-                isUnaired,
-                minCommunityRating,
-                minCriticRating,
-                minPremiereDate,
-                minDateLastSaved,
-                minDateLastSavedForUser,
-                maxPremiereDate,
-                hasOverview,
-                hasImdbId,
-                hasTmdbId,
-                hasTvdbId,
-                isMovie,
-                isSeries,
-                isNews,
-                isKids,
-                isSports,
-                excludeItemIds,
-                startIndex,
-                limit,
-                recursive,
-                searchTerm,
-                sortOrder,
-                parentId,
-                fields,
-                excludeItemTypes,
-                includeItemTypes,
-                filters,
-                isFavorite,
-                mediaTypes,
-                imageTypes,
-                sortBy,
-                isPlayed,
-                genres,
-                officialRatings,
-                tags,
-                years,
-                enableUserData,
-                imageTypeLimit,
-                enableImageTypes,
-                person,
-                personIds,
-                personTypes,
-                studios,
-                artists,
-                excludeArtistIds,
-                artistIds,
-                albumArtistIds,
-                contributingArtistIds,
-                albums,
-                albumIds,
-                ids,
-                videoTypes,
-                minOfficialRating,
-                isLocked,
-                isPlaceHolder,
-                hasOfficialRating,
-                collapseBoxSetItems,
-                minWidth,
-                minHeight,
-                maxWidth,
-                maxHeight,
-                is3D,
-                seriesStatus,
-                nameStartsWithOrGreater,
-                nameStartsWith,
-                nameLessThan,
-                studioIds,
-                genreIds,
-                enableTotalRecordCount,
-                enableImages);
+            var itemsArray = folder.GetChildren(user, true);
+            result = new QueryResult<BaseItem>(itemsArray);
         }
 
-        /// <summary>
-        /// Gets items based on a query.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="startIndex">The start index.</param>
-        /// <param name="limit">The item limit.</param>
-        /// <param name="searchTerm">The search term.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
-        /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
-        /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="excludeActiveSessions">Optional. Whether to exclude the currently active sessions.</param>
-        /// <response code="200">Items returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns>
-        [HttpGet("Users/{userId}/Items/Resume")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetResumeItems(
-            [FromRoute, Required] Guid userId,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] string? searchTerm,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery] bool enableTotalRecordCount = true,
-            [FromQuery] bool? enableImages = true,
-            [FromQuery] bool excludeActiveSessions = false)
-        {
-            var user = _userManager.GetUserById(userId);
-            var parentIdGuid = parentId ?? Guid.Empty;
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-
-            var ancestorIds = Array.Empty<Guid>();
+        return new QueryResult<BaseItemDto>(
+            startIndex,
+            result.TotalRecordCount,
+            _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user));
+    }
 
-            var excludeFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes);
-            if (parentIdGuid.Equals(default) && excludeFolderIds.Length > 0)
-            {
-                ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true)
-                    .Where(i => i is Folder)
-                    .Where(i => !excludeFolderIds.Contains(i.Id))
-                    .Select(i => i.Id)
-                    .ToArray();
-            }
+    /// <summary>
+    /// Gets items based on a query.
+    /// </summary>
+    /// <param name="userId">The user id supplied as query parameter.</param>
+    /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
+    /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
+    /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
+    /// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
+    /// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
+    /// <param name="hasTrailer">Optional filter by items with trailers.</param>
+    /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+    /// <param name="parentIndexNumber">Optional filter by parent index number.</param>
+    /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
+    /// <param name="isHd">Optional filter by items that are HD or not.</param>
+    /// <param name="is4K">Optional filter by items that are 4K or not.</param>
+    /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param>
+    /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param>
+    /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
+    /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
+    /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+    /// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
+    /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
+    /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
+    /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
+    /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
+    /// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
+    /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param>
+    /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param>
+    /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param>
+    /// <param name="isMovie">Optional filter for live tv movies.</param>
+    /// <param name="isSeries">Optional filter for live tv series.</param>
+    /// <param name="isNews">Optional filter for live tv news.</param>
+    /// <param name="isKids">Optional filter for live tv kids.</param>
+    /// <param name="isSports">Optional filter for live tv sports.</param>
+    /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
+    /// <param name="searchTerm">Optional. Filter based on a search term.</param>
+    /// <param name="sortOrder">Sort Order - Ascending, Descending.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+    /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+    /// <param name="isPlayed">Optional filter by items that are played, or not.</param>
+    /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+    /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+    /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+    /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
+    /// <param name="enableUserData">Optional, include user data.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+    /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
+    /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+    /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+    /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param>
+    /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param>
+    /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
+    /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
+    /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
+    /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param>
+    /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param>
+    /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
+    /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param>
+    /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
+    /// <param name="isLocked">Optional filter by items that are locked.</param>
+    /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
+    /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
+    /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
+    /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
+    /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
+    /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
+    /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
+    /// <param name="is3D">Optional filter by items that are 3D, or not.</param>
+    /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param>
+    /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+    /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+    /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+    /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+    /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
+    /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
+    [HttpGet("Users/{userId}/Items")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId(
+        [FromRoute] Guid userId,
+        [FromQuery] string? maxOfficialRating,
+        [FromQuery] bool? hasThemeSong,
+        [FromQuery] bool? hasThemeVideo,
+        [FromQuery] bool? hasSubtitles,
+        [FromQuery] bool? hasSpecialFeature,
+        [FromQuery] bool? hasTrailer,
+        [FromQuery] Guid? adjacentTo,
+        [FromQuery] int? parentIndexNumber,
+        [FromQuery] bool? hasParentalRating,
+        [FromQuery] bool? isHd,
+        [FromQuery] bool? is4K,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
+        [FromQuery] bool? isMissing,
+        [FromQuery] bool? isUnaired,
+        [FromQuery] double? minCommunityRating,
+        [FromQuery] double? minCriticRating,
+        [FromQuery] DateTime? minPremiereDate,
+        [FromQuery] DateTime? minDateLastSaved,
+        [FromQuery] DateTime? minDateLastSavedForUser,
+        [FromQuery] DateTime? maxPremiereDate,
+        [FromQuery] bool? hasOverview,
+        [FromQuery] bool? hasImdbId,
+        [FromQuery] bool? hasTmdbId,
+        [FromQuery] bool? hasTvdbId,
+        [FromQuery] bool? isMovie,
+        [FromQuery] bool? isSeries,
+        [FromQuery] bool? isNews,
+        [FromQuery] bool? isKids,
+        [FromQuery] bool? isSports,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] bool? recursive,
+        [FromQuery] string? searchTerm,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery] bool? isFavorite,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery] bool? isPlayed,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] string? person,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
+        [FromQuery] string? minOfficialRating,
+        [FromQuery] bool? isLocked,
+        [FromQuery] bool? isPlaceHolder,
+        [FromQuery] bool? hasOfficialRating,
+        [FromQuery] bool? collapseBoxSetItems,
+        [FromQuery] int? minWidth,
+        [FromQuery] int? minHeight,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] bool? is3D,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
+        [FromQuery] string? nameStartsWithOrGreater,
+        [FromQuery] string? nameStartsWith,
+        [FromQuery] string? nameLessThan,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery] bool enableTotalRecordCount = true,
+        [FromQuery] bool? enableImages = true)
+    {
+        return GetItems(
+            userId,
+            maxOfficialRating,
+            hasThemeSong,
+            hasThemeVideo,
+            hasSubtitles,
+            hasSpecialFeature,
+            hasTrailer,
+            adjacentTo,
+            parentIndexNumber,
+            hasParentalRating,
+            isHd,
+            is4K,
+            locationTypes,
+            excludeLocationTypes,
+            isMissing,
+            isUnaired,
+            minCommunityRating,
+            minCriticRating,
+            minPremiereDate,
+            minDateLastSaved,
+            minDateLastSavedForUser,
+            maxPremiereDate,
+            hasOverview,
+            hasImdbId,
+            hasTmdbId,
+            hasTvdbId,
+            isMovie,
+            isSeries,
+            isNews,
+            isKids,
+            isSports,
+            excludeItemIds,
+            startIndex,
+            limit,
+            recursive,
+            searchTerm,
+            sortOrder,
+            parentId,
+            fields,
+            excludeItemTypes,
+            includeItemTypes,
+            filters,
+            isFavorite,
+            mediaTypes,
+            imageTypes,
+            sortBy,
+            isPlayed,
+            genres,
+            officialRatings,
+            tags,
+            years,
+            enableUserData,
+            imageTypeLimit,
+            enableImageTypes,
+            person,
+            personIds,
+            personTypes,
+            studios,
+            artists,
+            excludeArtistIds,
+            artistIds,
+            albumArtistIds,
+            contributingArtistIds,
+            albums,
+            albumIds,
+            ids,
+            videoTypes,
+            minOfficialRating,
+            isLocked,
+            isPlaceHolder,
+            hasOfficialRating,
+            collapseBoxSetItems,
+            minWidth,
+            minHeight,
+            maxWidth,
+            maxHeight,
+            is3D,
+            seriesStatus,
+            nameStartsWithOrGreater,
+            nameStartsWith,
+            nameLessThan,
+            studioIds,
+            genreIds,
+            enableTotalRecordCount,
+            enableImages);
+    }
 
-            var excludeItemIds = Array.Empty<Guid>();
-            if (excludeActiveSessions)
-            {
-                excludeItemIds = _sessionManager.Sessions
-                    .Where(s => s.UserId.Equals(userId) && s.NowPlayingItem is not null)
-                    .Select(s => s.NowPlayingItem.Id)
-                    .ToArray();
-            }
+    /// <summary>
+    /// Gets items based on a query.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="startIndex">The start index.</param>
+    /// <param name="limit">The item limit.</param>
+    /// <param name="searchTerm">The search term.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+    /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
+    /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="excludeActiveSessions">Optional. Whether to exclude the currently active sessions.</param>
+    /// <response code="200">Items returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns>
+    [HttpGet("Users/{userId}/Items/Resume")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetResumeItems(
+        [FromRoute, Required] Guid userId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] string? searchTerm,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery] bool enableTotalRecordCount = true,
+        [FromQuery] bool? enableImages = true,
+        [FromQuery] bool excludeActiveSessions = false)
+    {
+        var user = _userManager.GetUserById(userId);
+        var parentIdGuid = parentId ?? Guid.Empty;
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-            var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
-            {
-                OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) },
-                IsResumable = true,
-                StartIndex = startIndex,
-                Limit = limit,
-                ParentId = parentIdGuid,
-                Recursive = true,
-                DtoOptions = dtoOptions,
-                MediaTypes = mediaTypes,
-                IsVirtualItem = false,
-                CollapseBoxSetItems = false,
-                EnableTotalRecordCount = enableTotalRecordCount,
-                AncestorIds = ancestorIds,
-                IncludeItemTypes = includeItemTypes,
-                ExcludeItemTypes = excludeItemTypes,
-                SearchTerm = searchTerm,
-                ExcludeItemIds = excludeItemIds
-            });
+        var ancestorIds = Array.Empty<Guid>();
 
-            var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, dtoOptions, user);
+        var excludeFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes);
+        if (parentIdGuid.Equals(default) && excludeFolderIds.Length > 0)
+        {
+            ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true)
+                .Where(i => i is Folder)
+                .Where(i => !excludeFolderIds.Contains(i.Id))
+                .Select(i => i.Id)
+                .ToArray();
+        }
 
-            return new QueryResult<BaseItemDto>(
-                startIndex,
-                itemsResult.TotalRecordCount,
-                returnItems);
+        var excludeItemIds = Array.Empty<Guid>();
+        if (excludeActiveSessions)
+        {
+            excludeItemIds = _sessionManager.Sessions
+                .Where(s => s.UserId.Equals(userId) && s.NowPlayingItem is not null)
+                .Select(s => s.NowPlayingItem.Id)
+                .ToArray();
         }
+
+        var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
+        {
+            OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) },
+            IsResumable = true,
+            StartIndex = startIndex,
+            Limit = limit,
+            ParentId = parentIdGuid,
+            Recursive = true,
+            DtoOptions = dtoOptions,
+            MediaTypes = mediaTypes,
+            IsVirtualItem = false,
+            CollapseBoxSetItems = false,
+            EnableTotalRecordCount = enableTotalRecordCount,
+            AncestorIds = ancestorIds,
+            IncludeItemTypes = includeItemTypes,
+            ExcludeItemTypes = excludeItemTypes,
+            SearchTerm = searchTerm,
+            ExcludeItemIds = excludeItemIds
+        });
+
+        var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, dtoOptions, user);
+
+        return new QueryResult<BaseItemDto>(
+            startIndex,
+            itemsResult.TotalRecordCount,
+            returnItems);
     }
 }

+ 780 - 781
Jellyfin.Api/Controllers/LibraryController.cs

@@ -37,936 +37,935 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Library Controller.
+/// </summary>
+[Route("")]
+public class LibraryController : BaseJellyfinApiController
 {
+    private readonly IProviderManager _providerManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IUserManager _userManager;
+    private readonly IDtoService _dtoService;
+    private readonly IActivityManager _activityManager;
+    private readonly ILocalizationManager _localization;
+    private readonly ILibraryMonitor _libraryMonitor;
+    private readonly ILogger<LibraryController> _logger;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+
     /// <summary>
-    /// Library Controller.
+    /// Initializes a new instance of the <see cref="LibraryController"/> class.
     /// </summary>
-    [Route("")]
-    public class LibraryController : BaseJellyfinApiController
+    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param>
+    /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+    /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{LibraryController}"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    public LibraryController(
+        IProviderManager providerManager,
+        ILibraryManager libraryManager,
+        IUserManager userManager,
+        IDtoService dtoService,
+        IActivityManager activityManager,
+        ILocalizationManager localization,
+        ILibraryMonitor libraryMonitor,
+        ILogger<LibraryController> logger,
+        IServerConfigurationManager serverConfigurationManager)
     {
-        private readonly IProviderManager _providerManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-        private readonly IDtoService _dtoService;
-        private readonly IActivityManager _activityManager;
-        private readonly ILocalizationManager _localization;
-        private readonly ILibraryMonitor _libraryMonitor;
-        private readonly ILogger<LibraryController> _logger;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="LibraryController"/> class.
-        /// </summary>
-        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param>
-        /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
-        /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{LibraryController}"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        public LibraryController(
-            IProviderManager providerManager,
-            ILibraryManager libraryManager,
-            IUserManager userManager,
-            IDtoService dtoService,
-            IActivityManager activityManager,
-            ILocalizationManager localization,
-            ILibraryMonitor libraryMonitor,
-            ILogger<LibraryController> logger,
-            IServerConfigurationManager serverConfigurationManager)
-        {
-            _providerManager = providerManager;
-            _libraryManager = libraryManager;
-            _userManager = userManager;
-            _dtoService = dtoService;
-            _activityManager = activityManager;
-            _localization = localization;
-            _libraryMonitor = libraryMonitor;
-            _logger = logger;
-            _serverConfigurationManager = serverConfigurationManager;
-        }
-
-        /// <summary>
-        /// Get the original file of an item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <response code="200">File stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="FileStreamResult"/> with the original file.</returns>
-        [HttpGet("Items/{itemId}/File")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesFile("video/*", "audio/*")]
-        public ActionResult GetFile([FromRoute, Required] Guid itemId)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
-
-            return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true);
-        }
+        _providerManager = providerManager;
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+        _dtoService = dtoService;
+        _activityManager = activityManager;
+        _localization = localization;
+        _libraryMonitor = libraryMonitor;
+        _logger = logger;
+        _serverConfigurationManager = serverConfigurationManager;
+    }
 
-        /// <summary>
-        /// Gets critic review for an item.
-        /// </summary>
-        /// <response code="200">Critic reviews returned.</response>
-        /// <returns>The list of critic reviews.</returns>
-        [HttpGet("Items/{itemId}/CriticReviews")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [Obsolete("This endpoint is obsolete.")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetCriticReviews()
+    /// <summary>
+    /// Get the original file of an item.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <response code="200">File stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="FileStreamResult"/> with the original file.</returns>
+    [HttpGet("Items/{itemId}/File")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesFile("video/*", "audio/*")]
+    public ActionResult GetFile([FromRoute, Required] Guid itemId)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
         {
-            return new QueryResult<BaseItemDto>();
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get theme songs for an item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
-        /// <response code="200">Theme songs returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>The item theme songs.</returns>
-        [HttpGet("Items/{itemId}/ThemeSongs")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<ThemeMediaResult> GetThemeSongs(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] Guid? userId,
-            [FromQuery] bool inheritFromParent = false)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-
-            var item = itemId.Equals(default)
-                ? (userId is null || userId.Value.Equals(default)
-                    ? _libraryManager.RootFolder
-                    : _libraryManager.GetUserRootFolder())
-                : _libraryManager.GetItemById(itemId);
-
-            if (item is null)
-            {
-                return NotFound("Item not found.");
-            }
+        return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true);
+    }
 
-            IEnumerable<BaseItem> themeItems;
+    /// <summary>
+    /// Gets critic review for an item.
+    /// </summary>
+    /// <response code="200">Critic reviews returned.</response>
+    /// <returns>The list of critic reviews.</returns>
+    [HttpGet("Items/{itemId}/CriticReviews")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [Obsolete("This endpoint is obsolete.")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetCriticReviews()
+    {
+        return new QueryResult<BaseItemDto>();
+    }
 
-            while (true)
-            {
-                themeItems = item.GetThemeSongs();
+    /// <summary>
+    /// Get theme songs for an item.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
+    /// <response code="200">Theme songs returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>The item theme songs.</returns>
+    [HttpGet("Items/{itemId}/ThemeSongs")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<ThemeMediaResult> GetThemeSongs(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] Guid? userId,
+        [FromQuery] bool inheritFromParent = false)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-                if (themeItems.Any() || !inheritFromParent)
-                {
-                    break;
-                }
+        var item = itemId.Equals(default)
+            ? (userId is null || userId.Value.Equals(default)
+                ? _libraryManager.RootFolder
+                : _libraryManager.GetUserRootFolder())
+            : _libraryManager.GetItemById(itemId);
 
-                var parent = item.GetParent();
-                if (parent is null)
-                {
-                    break;
-                }
+        if (item is null)
+        {
+            return NotFound("Item not found.");
+        }
 
-                item = parent;
-            }
+        IEnumerable<BaseItem> themeItems;
 
-            var dtoOptions = new DtoOptions().AddClientFields(User);
-            var items = themeItems
-                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
-                .ToArray();
+        while (true)
+        {
+            themeItems = item.GetThemeSongs();
 
-            return new ThemeMediaResult
+            if (themeItems.Any() || !inheritFromParent)
             {
-                Items = items,
-                TotalRecordCount = items.Length,
-                OwnerId = item.Id
-            };
-        }
-
-        /// <summary>
-        /// Get theme videos for an item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
-        /// <response code="200">Theme videos returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>The item theme videos.</returns>
-        [HttpGet("Items/{itemId}/ThemeVideos")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<ThemeMediaResult> GetThemeVideos(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] Guid? userId,
-            [FromQuery] bool inheritFromParent = false)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-
-            var item = itemId.Equals(default)
-                ? (userId is null || userId.Value.Equals(default)
-                    ? _libraryManager.RootFolder
-                    : _libraryManager.GetUserRootFolder())
-                : _libraryManager.GetItemById(itemId);
-
-            if (item is null)
+                break;
+            }
+
+            var parent = item.GetParent();
+            if (parent is null)
             {
-                return NotFound("Item not found.");
+                break;
             }
 
-            IEnumerable<BaseItem> themeItems;
+            item = parent;
+        }
 
-            while (true)
-            {
-                themeItems = item.GetThemeVideos();
+        var dtoOptions = new DtoOptions().AddClientFields(User);
+        var items = themeItems
+            .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
+            .ToArray();
 
-                if (themeItems.Any() || !inheritFromParent)
-                {
-                    break;
-                }
+        return new ThemeMediaResult
+        {
+            Items = items,
+            TotalRecordCount = items.Length,
+            OwnerId = item.Id
+        };
+    }
 
-                var parent = item.GetParent();
-                if (parent is null)
-                {
-                    break;
-                }
+    /// <summary>
+    /// Get theme videos for an item.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
+    /// <response code="200">Theme videos returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>The item theme videos.</returns>
+    [HttpGet("Items/{itemId}/ThemeVideos")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<ThemeMediaResult> GetThemeVideos(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] Guid? userId,
+        [FromQuery] bool inheritFromParent = false)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-                item = parent;
-            }
+        var item = itemId.Equals(default)
+            ? (userId is null || userId.Value.Equals(default)
+                ? _libraryManager.RootFolder
+                : _libraryManager.GetUserRootFolder())
+            : _libraryManager.GetItemById(itemId);
 
-            var dtoOptions = new DtoOptions().AddClientFields(User);
-            var items = themeItems
-                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
-                .ToArray();
+        if (item is null)
+        {
+            return NotFound("Item not found.");
+        }
 
-            return new ThemeMediaResult
-            {
-                Items = items,
-                TotalRecordCount = items.Length,
-                OwnerId = item.Id
-            };
-        }
-
-        /// <summary>
-        /// Get theme songs and videos for an item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
-        /// <response code="200">Theme songs and videos returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>The item theme videos.</returns>
-        [HttpGet("Items/{itemId}/ThemeMedia")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<AllThemeMediaResult> GetThemeMedia(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] Guid? userId,
-            [FromQuery] bool inheritFromParent = false)
-        {
-            var themeSongs = GetThemeSongs(
-                itemId,
-                userId,
-                inheritFromParent);
-
-            var themeVideos = GetThemeVideos(
-                itemId,
-                userId,
-                inheritFromParent);
-
-            return new AllThemeMediaResult
-            {
-                ThemeSongsResult = themeSongs?.Value,
-                ThemeVideosResult = themeVideos?.Value,
-                SoundtrackSongsResult = new ThemeMediaResult()
-            };
-        }
-
-        /// <summary>
-        /// Starts a library scan.
-        /// </summary>
-        /// <response code="204">Library scan started.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Library/Refresh")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> RefreshLibrary()
-        {
-            try
+        IEnumerable<BaseItem> themeItems;
+
+        while (true)
+        {
+            themeItems = item.GetThemeVideos();
+
+            if (themeItems.Any() || !inheritFromParent)
             {
-                await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
+                break;
             }
-            catch (Exception ex)
+
+            var parent = item.GetParent();
+            if (parent is null)
             {
-                 _logger.LogError(ex, "Error refreshing library");
+                break;
             }
 
+            item = parent;
+        }
+
+        var dtoOptions = new DtoOptions().AddClientFields(User);
+        var items = themeItems
+            .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
+            .ToArray();
+
+        return new ThemeMediaResult
+        {
+            Items = items,
+            TotalRecordCount = items.Length,
+            OwnerId = item.Id
+        };
+    }
+
+    /// <summary>
+    /// Get theme songs and videos for an item.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
+    /// <response code="200">Theme songs and videos returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>The item theme videos.</returns>
+    [HttpGet("Items/{itemId}/ThemeMedia")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<AllThemeMediaResult> GetThemeMedia(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] Guid? userId,
+        [FromQuery] bool inheritFromParent = false)
+    {
+        var themeSongs = GetThemeSongs(
+            itemId,
+            userId,
+            inheritFromParent);
+
+        var themeVideos = GetThemeVideos(
+            itemId,
+            userId,
+            inheritFromParent);
+
+        return new AllThemeMediaResult
+        {
+            ThemeSongsResult = themeSongs?.Value,
+            ThemeVideosResult = themeVideos?.Value,
+            SoundtrackSongsResult = new ThemeMediaResult()
+        };
+    }
+
+    /// <summary>
+    /// Starts a library scan.
+    /// </summary>
+    /// <response code="204">Library scan started.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Library/Refresh")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> RefreshLibrary()
+    {
+        try
+        {
+            await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error refreshing library");
+        }
+
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Deletes an item from the library and filesystem.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <response code="204">Item deleted.</response>
+    /// <response code="401">Unauthorized access.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Items/{itemId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+    public ActionResult DeleteItem(Guid itemId)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        var user = _userManager.GetUserById(User.GetUserId());
+
+        if (!item.CanDelete(user))
+        {
+            return Unauthorized("Unauthorized access");
+        }
+
+        _libraryManager.DeleteItem(
+            item,
+            new DeleteOptions { DeleteFileLocation = true },
+            true);
+
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Deletes items from the library and filesystem.
+    /// </summary>
+    /// <param name="ids">The item ids.</param>
+    /// <response code="204">Items deleted.</response>
+    /// <response code="401">Unauthorized access.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Items")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+    public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+    {
+        if (ids.Length == 0)
+        {
             return NoContent();
         }
 
-        /// <summary>
-        /// Deletes an item from the library and filesystem.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <response code="204">Item deleted.</response>
-        /// <response code="401">Unauthorized access.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Items/{itemId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status401Unauthorized)]
-        public ActionResult DeleteItem(Guid itemId)
-        {
-            var item = _libraryManager.GetItemById(itemId);
+        foreach (var i in ids)
+        {
+            var item = _libraryManager.GetItemById(i);
             var user = _userManager.GetUserById(User.GetUserId());
 
             if (!item.CanDelete(user))
             {
-                return Unauthorized("Unauthorized access");
+                if (ids.Length > 1)
+                {
+                    return Unauthorized("Unauthorized access");
+                }
+
+                continue;
             }
 
             _libraryManager.DeleteItem(
                 item,
                 new DeleteOptions { DeleteFileLocation = true },
                 true);
-
-            return NoContent();
         }
 
-        /// <summary>
-        /// Deletes items from the library and filesystem.
-        /// </summary>
-        /// <param name="ids">The item ids.</param>
-        /// <response code="204">Items deleted.</response>
-        /// <response code="401">Unauthorized access.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Items")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status401Unauthorized)]
-        public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
-        {
-            if (ids.Length == 0)
-            {
-                return NoContent();
-            }
-
-            foreach (var i in ids)
-            {
-                var item = _libraryManager.GetItemById(i);
-                var user = _userManager.GetUserById(User.GetUserId());
+        return NoContent();
+    }
 
-                if (!item.CanDelete(user))
-                {
-                    if (ids.Length > 1)
-                    {
-                        return Unauthorized("Unauthorized access");
-                    }
+    /// <summary>
+    /// Get item counts.
+    /// </summary>
+    /// <param name="userId">Optional. Get counts from a specific user's library.</param>
+    /// <param name="isFavorite">Optional. Get counts of favorite items.</param>
+    /// <response code="200">Item counts returned.</response>
+    /// <returns>Item counts.</returns>
+    [HttpGet("Items/Counts")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<ItemCounts> GetItemCounts(
+        [FromQuery] Guid? userId,
+        [FromQuery] bool? isFavorite)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-                    continue;
-                }
+        var counts = new ItemCounts
+        {
+            AlbumCount = GetCount(BaseItemKind.MusicAlbum, user, isFavorite),
+            EpisodeCount = GetCount(BaseItemKind.Episode, user, isFavorite),
+            MovieCount = GetCount(BaseItemKind.Movie, user, isFavorite),
+            SeriesCount = GetCount(BaseItemKind.Series, user, isFavorite),
+            SongCount = GetCount(BaseItemKind.Audio, user, isFavorite),
+            MusicVideoCount = GetCount(BaseItemKind.MusicVideo, user, isFavorite),
+            BoxSetCount = GetCount(BaseItemKind.BoxSet, user, isFavorite),
+            BookCount = GetCount(BaseItemKind.Book, user, isFavorite)
+        };
+
+        return counts;
+    }
 
-                _libraryManager.DeleteItem(
-                    item,
-                    new DeleteOptions { DeleteFileLocation = true },
-                    true);
-            }
+    /// <summary>
+    /// Gets all parents of an item.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <response code="200">Item parents returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>Item parents.</returns>
+    [HttpGet("Items/{itemId}/Ancestors")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
+    {
+        var item = _libraryManager.GetItemById(itemId);
 
-            return NoContent();
+        if (item is null)
+        {
+            return NotFound("Item not found");
         }
 
-        /// <summary>
-        /// Get item counts.
-        /// </summary>
-        /// <param name="userId">Optional. Get counts from a specific user's library.</param>
-        /// <param name="isFavorite">Optional. Get counts of favorite items.</param>
-        /// <response code="200">Item counts returned.</response>
-        /// <returns>Item counts.</returns>
-        [HttpGet("Items/Counts")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<ItemCounts> GetItemCounts(
-            [FromQuery] Guid? userId,
-            [FromQuery] bool? isFavorite)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-
-            var counts = new ItemCounts
-            {
-                AlbumCount = GetCount(BaseItemKind.MusicAlbum, user, isFavorite),
-                EpisodeCount = GetCount(BaseItemKind.Episode, user, isFavorite),
-                MovieCount = GetCount(BaseItemKind.Movie, user, isFavorite),
-                SeriesCount = GetCount(BaseItemKind.Series, user, isFavorite),
-                SongCount = GetCount(BaseItemKind.Audio, user, isFavorite),
-                MusicVideoCount = GetCount(BaseItemKind.MusicVideo, user, isFavorite),
-                BoxSetCount = GetCount(BaseItemKind.BoxSet, user, isFavorite),
-                BookCount = GetCount(BaseItemKind.Book, user, isFavorite)
-            };
-
-            return counts;
-        }
-
-        /// <summary>
-        /// Gets all parents of an item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <response code="200">Item parents returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>Item parents.</returns>
-        [HttpGet("Items/{itemId}/Ancestors")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-
-            if (item is null)
+        var baseItemDtos = new List<BaseItemDto>();
+
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+
+        var dtoOptions = new DtoOptions().AddClientFields(User);
+        BaseItem? parent = item.GetParent();
+
+        while (parent is not null)
+        {
+            if (user is not null)
             {
-                return NotFound("Item not found");
+                parent = TranslateParentItem(parent, user);
             }
 
-            var baseItemDtos = new List<BaseItemDto>();
+            baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user));
 
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+            parent = parent?.GetParent();
+        }
 
-            var dtoOptions = new DtoOptions().AddClientFields(User);
-            BaseItem? parent = item.GetParent();
+        return baseItemDtos;
+    }
 
-            while (parent is not null)
-            {
-                if (user is not null)
-                {
-                    parent = TranslateParentItem(parent, user);
-                }
+    /// <summary>
+    /// Gets a list of physical paths from virtual folders.
+    /// </summary>
+    /// <response code="200">Physical paths returned.</response>
+    /// <returns>List of physical paths.</returns>
+    [HttpGet("Library/PhysicalPaths")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<string>> GetPhysicalPaths()
+    {
+        return Ok(_libraryManager.RootFolder.Children
+            .SelectMany(c => c.PhysicalLocations));
+    }
 
-                baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user));
+    /// <summary>
+    /// Gets all user media folders.
+    /// </summary>
+    /// <param name="isHidden">Optional. Filter by folders that are marked hidden, or not.</param>
+    /// <response code="200">Media folders returned.</response>
+    /// <returns>List of user media folders.</returns>
+    [HttpGet("Library/MediaFolders")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden)
+    {
+        var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList();
 
-                parent = parent?.GetParent();
-            }
+        if (isHidden.HasValue)
+        {
+            var val = isHidden.Value;
 
-            return baseItemDtos;
+            items = items.Where(i => i.IsHidden == val).ToList();
         }
 
-        /// <summary>
-        /// Gets a list of physical paths from virtual folders.
-        /// </summary>
-        /// <response code="200">Physical paths returned.</response>
-        /// <returns>List of physical paths.</returns>
-        [HttpGet("Library/PhysicalPaths")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<string>> GetPhysicalPaths()
+        var dtoOptions = new DtoOptions().AddClientFields(User);
+        var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions);
+        return new QueryResult<BaseItemDto>(resultArray);
+    }
+
+    /// <summary>
+    /// Reports that new episodes of a series have been added by an external source.
+    /// </summary>
+    /// <param name="tvdbId">The tvdbId.</param>
+    /// <response code="204">Report success.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Library/Series/Added", Name = "PostAddedSeries")]
+    [HttpPost("Library/Series/Updated")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult PostUpdatedSeries([FromQuery] string? tvdbId)
+    {
+        var series = _libraryManager.GetItemList(new InternalItemsQuery
         {
-            return Ok(_libraryManager.RootFolder.Children
-                .SelectMany(c => c.PhysicalLocations));
-        }
+            IncludeItemTypes = new[] { BaseItemKind.Series },
+            DtoOptions = new DtoOptions(false)
+            {
+                EnableImages = false
+            }
+        }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray();
 
-        /// <summary>
-        /// Gets all user media folders.
-        /// </summary>
-        /// <param name="isHidden">Optional. Filter by folders that are marked hidden, or not.</param>
-        /// <response code="200">Media folders returned.</response>
-        /// <returns>List of user media folders.</returns>
-        [HttpGet("Library/MediaFolders")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden)
+        foreach (var item in series)
         {
-            var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList();
+            _libraryMonitor.ReportFileSystemChanged(item.Path);
+        }
 
-            if (isHidden.HasValue)
-            {
-                var val = isHidden.Value;
+        return NoContent();
+    }
 
-                items = items.Where(i => i.IsHidden == val).ToList();
+    /// <summary>
+    /// Reports that new movies have been added by an external source.
+    /// </summary>
+    /// <param name="tmdbId">The tmdbId.</param>
+    /// <param name="imdbId">The imdbId.</param>
+    /// <response code="204">Report success.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Library/Movies/Added", Name = "PostAddedMovies")]
+    [HttpPost("Library/Movies/Updated")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult PostUpdatedMovies([FromQuery] string? tmdbId, [FromQuery] string? imdbId)
+    {
+        var movies = _libraryManager.GetItemList(new InternalItemsQuery
+        {
+            IncludeItemTypes = new[] { BaseItemKind.Movie },
+            DtoOptions = new DtoOptions(false)
+            {
+                EnableImages = false
             }
+        });
+
+        if (!string.IsNullOrWhiteSpace(imdbId))
+        {
+            movies = movies.Where(i => string.Equals(imdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList();
+        }
+        else if (!string.IsNullOrWhiteSpace(tmdbId))
+        {
+            movies = movies.Where(i => string.Equals(tmdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList();
+        }
+        else
+        {
+            movies = new List<BaseItem>();
+        }
 
-            var dtoOptions = new DtoOptions().AddClientFields(User);
-            var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions);
-            return new QueryResult<BaseItemDto>(resultArray);
+        foreach (var item in movies)
+        {
+            _libraryMonitor.ReportFileSystemChanged(item.Path);
         }
 
-        /// <summary>
-        /// Reports that new episodes of a series have been added by an external source.
-        /// </summary>
-        /// <param name="tvdbId">The tvdbId.</param>
-        /// <response code="204">Report success.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Library/Series/Added", Name = "PostAddedSeries")]
-        [HttpPost("Library/Series/Updated")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult PostUpdatedSeries([FromQuery] string? tvdbId)
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Reports that new movies have been added by an external source.
+    /// </summary>
+    /// <param name="dto">The update paths.</param>
+    /// <response code="204">Report success.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Library/Media/Updated")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult PostUpdatedMedia([FromBody, Required] MediaUpdateInfoDto dto)
+    {
+        foreach (var item in dto.Updates)
         {
-            var series = _libraryManager.GetItemList(new InternalItemsQuery
-            {
-                IncludeItemTypes = new[] { BaseItemKind.Series },
-                DtoOptions = new DtoOptions(false)
-                {
-                    EnableImages = false
-                }
-            }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray();
+            _libraryMonitor.ReportFileSystemChanged(item.Path ?? throw new ArgumentException("Item path can't be null."));
+        }
 
-            foreach (var item in series)
-            {
-                _libraryMonitor.ReportFileSystemChanged(item.Path);
-            }
+        return NoContent();
+    }
 
-            return NoContent();
+    /// <summary>
+    /// Downloads item media.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <response code="200">Media downloaded.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="FileResult"/> containing the media stream.</returns>
+    /// <exception cref="ArgumentException">User can't download or item can't be downloaded.</exception>
+    [HttpGet("Items/{itemId}/Download")]
+    [Authorize(Policy = Policies.Download)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesFile("video/*", "audio/*")]
+    public async Task<ActionResult> GetDownload([FromRoute, Required] Guid itemId)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Reports that new movies have been added by an external source.
-        /// </summary>
-        /// <param name="tmdbId">The tmdbId.</param>
-        /// <param name="imdbId">The imdbId.</param>
-        /// <response code="204">Report success.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Library/Movies/Added", Name = "PostAddedMovies")]
-        [HttpPost("Library/Movies/Updated")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult PostUpdatedMovies([FromQuery] string? tmdbId, [FromQuery] string? imdbId)
-        {
-            var movies = _libraryManager.GetItemList(new InternalItemsQuery
-            {
-                IncludeItemTypes = new[] { BaseItemKind.Movie },
-                DtoOptions = new DtoOptions(false)
-                {
-                    EnableImages = false
-                }
-            });
+        var user = _userManager.GetUserById(User.GetUserId());
 
-            if (!string.IsNullOrWhiteSpace(imdbId))
-            {
-                movies = movies.Where(i => string.Equals(imdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList();
-            }
-            else if (!string.IsNullOrWhiteSpace(tmdbId))
+        if (user is not null)
+        {
+            if (!item.CanDownload(user))
             {
-                movies = movies.Where(i => string.Equals(tmdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList();
+                throw new ArgumentException("Item does not support downloading");
             }
-            else
+        }
+        else
+        {
+            if (!item.CanDownload())
             {
-                movies = new List<BaseItem>();
+                throw new ArgumentException("Item does not support downloading");
             }
+        }
 
-            foreach (var item in movies)
-            {
-                _libraryMonitor.ReportFileSystemChanged(item.Path);
-            }
+        if (user is not null)
+        {
+            await LogDownloadAsync(item, user).ConfigureAwait(false);
+        }
 
-            return NoContent();
+        // Quotes are valid in linux. They'll possibly cause issues here.
+        var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
+
+        return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true);
+    }
+
+    /// <summary>
+    /// Gets similar items.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="excludeArtistIds">Exclude artist ids.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+    /// <response code="200">Similar items returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns>
+    [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")]
+    [HttpGet("Items/{itemId}/Similar")]
+    [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums")]
+    [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows")]
+    [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies")]
+    [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
+    {
+        var item = itemId.Equals(default)
+            ? (userId is null || userId.Value.Equals(default)
+                ? _libraryManager.RootFolder
+                : _libraryManager.GetUserRootFolder())
+            : _libraryManager.GetItemById(itemId);
+
+        if (item is Episode || (item is IItemByName && item is not MusicArtist))
+        {
+            return new QueryResult<BaseItemDto>();
         }
 
-        /// <summary>
-        /// Reports that new movies have been added by an external source.
-        /// </summary>
-        /// <param name="dto">The update paths.</param>
-        /// <response code="204">Report success.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Library/Media/Updated")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult PostUpdatedMedia([FromBody, Required] MediaUpdateInfoDto dto)
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User);
+
+        var program = item as IHasProgramAttributes;
+        bool? isMovie = item is Movie || (program is not null && program.IsMovie) || item is Trailer;
+        bool? isSeries = item is Series || (program is not null && program.IsSeries);
+
+        var includeItemTypes = new List<BaseItemKind>();
+        if (isMovie.Value)
         {
-            foreach (var item in dto.Updates)
+            includeItemTypes.Add(BaseItemKind.Movie);
+            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
             {
-                _libraryMonitor.ReportFileSystemChanged(item.Path ?? throw new ArgumentException("Item path can't be null."));
+                includeItemTypes.Add(BaseItemKind.Trailer);
+                includeItemTypes.Add(BaseItemKind.LiveTvProgram);
             }
-
-            return NoContent();
+        }
+        else if (isSeries.Value)
+        {
+            includeItemTypes.Add(BaseItemKind.Series);
+        }
+        else
+        {
+            // For non series and movie types these columns are typically null
+            // isSeries = null;
+            isMovie = null;
+            includeItemTypes.Add(item.GetBaseItemKind());
         }
 
-        /// <summary>
-        /// Downloads item media.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <response code="200">Media downloaded.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="FileResult"/> containing the media stream.</returns>
-        /// <exception cref="ArgumentException">User can't download or item can't be downloaded.</exception>
-        [HttpGet("Items/{itemId}/Download")]
-        [Authorize(Policy = Policies.Download)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesFile("video/*", "audio/*")]
-        public async Task<ActionResult> GetDownload([FromRoute, Required] Guid itemId)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        var query = new InternalItemsQuery(user)
+        {
+            Genres = item.Genres,
+            Limit = limit,
+            IncludeItemTypes = includeItemTypes.ToArray(),
+            SimilarTo = item,
+            DtoOptions = dtoOptions,
+            EnableTotalRecordCount = !isMovie ?? true,
+            EnableGroupByMetadataKey = isMovie ?? false,
+            MinSimilarityScore = 2 // A remnant from album/artist scoring
+        };
+
+        // ExcludeArtistIds
+        if (excludeArtistIds.Length != 0)
+        {
+            query.ExcludeArtistIds = excludeArtistIds;
+        }
 
-            var user = _userManager.GetUserById(User.GetUserId());
+        List<BaseItem> itemsResult = _libraryManager.GetItemList(query);
 
-            if (user is not null)
-            {
-                if (!item.CanDownload(user))
-                {
-                    throw new ArgumentException("Item does not support downloading");
-                }
-            }
-            else
-            {
-                if (!item.CanDownload())
-                {
-                    throw new ArgumentException("Item does not support downloading");
-                }
-            }
+        var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user);
 
-            if (user is not null)
-            {
-                await LogDownloadAsync(item, user).ConfigureAwait(false);
-            }
+        return new QueryResult<BaseItemDto>(
+            query.StartIndex,
+            itemsResult.Count,
+            returnList);
+    }
 
-            // Quotes are valid in linux. They'll possibly cause issues here.
-            var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
-
-            return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true);
-        }
-
-        /// <summary>
-        /// Gets similar items.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="excludeArtistIds">Exclude artist ids.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
-        /// <response code="200">Similar items returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns>
-        [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")]
-        [HttpGet("Items/{itemId}/Similar")]
-        [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums")]
-        [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows")]
-        [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies")]
-        [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
-        {
-            var item = itemId.Equals(default)
-                ? (userId is null || userId.Value.Equals(default)
-                    ? _libraryManager.RootFolder
-                    : _libraryManager.GetUserRootFolder())
-                : _libraryManager.GetItemById(itemId);
-
-            if (item is Episode || (item is IItemByName && item is not MusicArtist))
-            {
-                return new QueryResult<BaseItemDto>();
-            }
+    /// <summary>
+    /// Gets the library options info.
+    /// </summary>
+    /// <param name="libraryContentType">Library content type.</param>
+    /// <param name="isNewLibrary">Whether this is a new library.</param>
+    /// <response code="200">Library options info returned.</response>
+    /// <returns>Library options info.</returns>
+    [HttpGet("Libraries/AvailableOptions")]
+    [Authorize(Policy = Policies.FirstTimeSetupOrDefault)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo(
+        [FromQuery] string? libraryContentType,
+        [FromQuery] bool isNewLibrary = false)
+    {
+        var result = new LibraryOptionsResultDto();
 
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User);
+        var types = GetRepresentativeItemTypes(libraryContentType);
+        var typesList = types.ToList();
 
-            var program = item as IHasProgramAttributes;
-            bool? isMovie = item is Movie || (program is not null && program.IsMovie) || item is Trailer;
-            bool? isSeries = item is Series || (program is not null && program.IsSeries);
+        var plugins = _providerManager.GetAllMetadataPlugins()
+            .Where(i => types.Contains(i.ItemType, StringComparison.OrdinalIgnoreCase))
+            .OrderBy(i => typesList.IndexOf(i.ItemType))
+            .ToList();
 
-            var includeItemTypes = new List<BaseItemKind>();
-            if (isMovie.Value)
+        result.MetadataSavers = plugins
+            .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver))
+            .Select(i => new LibraryOptionInfoDto
             {
-                includeItemTypes.Add(BaseItemKind.Movie);
-                if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
-                {
-                    includeItemTypes.Add(BaseItemKind.Trailer);
-                    includeItemTypes.Add(BaseItemKind.LiveTvProgram);
-                }
-            }
-            else if (isSeries.Value)
-            {
-                includeItemTypes.Add(BaseItemKind.Series);
-            }
-            else
-            {
-                // For non series and movie types these columns are typically null
-                // isSeries = null;
-                isMovie = null;
-                includeItemTypes.Add(item.GetBaseItemKind());
-            }
+                Name = i.Name,
+                DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary)
+            })
+            .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+            .ToArray();
 
-            var query = new InternalItemsQuery(user)
-            {
-                Genres = item.Genres,
-                Limit = limit,
-                IncludeItemTypes = includeItemTypes.ToArray(),
-                SimilarTo = item,
-                DtoOptions = dtoOptions,
-                EnableTotalRecordCount = !isMovie ?? true,
-                EnableGroupByMetadataKey = isMovie ?? false,
-                MinSimilarityScore = 2 // A remnant from album/artist scoring
-            };
-
-            // ExcludeArtistIds
-            if (excludeArtistIds.Length != 0)
+        result.MetadataReaders = plugins
+            .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider))
+            .Select(i => new LibraryOptionInfoDto
             {
-                query.ExcludeArtistIds = excludeArtistIds;
-            }
-
-            List<BaseItem> itemsResult = _libraryManager.GetItemList(query);
+                Name = i.Name,
+                DefaultEnabled = true
+            })
+            .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+            .ToArray();
 
-            var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user);
+        result.SubtitleFetchers = plugins
+            .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher))
+            .Select(i => new LibraryOptionInfoDto
+            {
+                Name = i.Name,
+                DefaultEnabled = true
+            })
+            .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+            .ToArray();
 
-            return new QueryResult<BaseItemDto>(
-                query.StartIndex,
-                itemsResult.Count,
-                returnList);
-        }
+        var typeOptions = new List<LibraryTypeOptionsDto>();
 
-        /// <summary>
-        /// Gets the library options info.
-        /// </summary>
-        /// <param name="libraryContentType">Library content type.</param>
-        /// <param name="isNewLibrary">Whether this is a new library.</param>
-        /// <response code="200">Library options info returned.</response>
-        /// <returns>Library options info.</returns>
-        [HttpGet("Libraries/AvailableOptions")]
-        [Authorize(Policy = Policies.FirstTimeSetupOrDefault)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo(
-            [FromQuery] string? libraryContentType,
-            [FromQuery] bool isNewLibrary = false)
+        foreach (var type in types)
         {
-            var result = new LibraryOptionsResultDto();
-
-            var types = GetRepresentativeItemTypes(libraryContentType);
-            var typesList = types.ToList();
+            TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions);
 
-            var plugins = _providerManager.GetAllMetadataPlugins()
-                .Where(i => types.Contains(i.ItemType, StringComparison.OrdinalIgnoreCase))
-                .OrderBy(i => typesList.IndexOf(i.ItemType))
-                .ToList();
+            typeOptions.Add(new LibraryTypeOptionsDto
+            {
+                Type = type,
 
-            result.MetadataSavers = plugins
-                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver))
+                MetadataFetchers = plugins
+                .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher))
                 .Select(i => new LibraryOptionInfoDto
                 {
                     Name = i.Name,
-                    DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary)
+                    DefaultEnabled = IsMetadataFetcherEnabledByDefault(i.Name, type, isNewLibrary)
                 })
                 .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
-                .ToArray();
+                .ToArray(),
 
-            result.MetadataReaders = plugins
-                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider))
+                ImageFetchers = plugins
+                .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher))
                 .Select(i => new LibraryOptionInfoDto
                 {
                     Name = i.Name,
-                    DefaultEnabled = true
+                    DefaultEnabled = IsImageFetcherEnabledByDefault(i.Name, type, isNewLibrary)
                 })
                 .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
-                .ToArray();
+                .ToArray(),
 
-            result.SubtitleFetchers = plugins
-                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher))
-                .Select(i => new LibraryOptionInfoDto
-                {
-                    Name = i.Name,
-                    DefaultEnabled = true
-                })
-                .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
-                .ToArray();
+                SupportedImageTypes = plugins
+                .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+                .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>())
+                .Distinct()
+                .ToArray(),
 
-            var typeOptions = new List<LibraryTypeOptionsDto>();
+                DefaultImageOptions = defaultImageOptions ?? Array.Empty<ImageOption>()
+            });
+        }
 
-            foreach (var type in types)
-            {
-                TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions);
+        result.TypeOptions = typeOptions.ToArray();
 
-                typeOptions.Add(new LibraryTypeOptionsDto
-                {
-                    Type = type,
-
-                    MetadataFetchers = plugins
-                    .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
-                    .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher))
-                    .Select(i => new LibraryOptionInfoDto
-                    {
-                        Name = i.Name,
-                        DefaultEnabled = IsMetadataFetcherEnabledByDefault(i.Name, type, isNewLibrary)
-                    })
-                    .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
-                    .ToArray(),
-
-                    ImageFetchers = plugins
-                    .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
-                    .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher))
-                    .Select(i => new LibraryOptionInfoDto
-                    {
-                        Name = i.Name,
-                        DefaultEnabled = IsImageFetcherEnabledByDefault(i.Name, type, isNewLibrary)
-                    })
-                    .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
-                    .ToArray(),
-
-                    SupportedImageTypes = plugins
-                    .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
-                    .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>())
-                    .Distinct()
-                    .ToArray(),
-
-                    DefaultImageOptions = defaultImageOptions ?? Array.Empty<ImageOption>()
-                });
+        return result;
+    }
+
+    private int GetCount(BaseItemKind itemKind, User? user, bool? isFavorite)
+    {
+        var query = new InternalItemsQuery(user)
+        {
+            IncludeItemTypes = new[] { itemKind },
+            Limit = 0,
+            Recursive = true,
+            IsVirtualItem = false,
+            IsFavorite = isFavorite,
+            DtoOptions = new DtoOptions(false)
+            {
+                EnableImages = false
             }
+        };
 
-            result.TypeOptions = typeOptions.ToArray();
+        return _libraryManager.GetItemsResult(query).TotalRecordCount;
+    }
 
-            return result;
-        }
+    private BaseItem? TranslateParentItem(BaseItem item, User user)
+    {
+        return item.GetParent() is AggregateFolder
+            ? _libraryManager.GetUserRootFolder().GetChildren(user, true)
+                .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path))
+            : item;
+    }
 
-        private int GetCount(BaseItemKind itemKind, User? user, bool? isFavorite)
+    private async Task LogDownloadAsync(BaseItem item, User user)
+    {
+        try
         {
-            var query = new InternalItemsQuery(user)
+            await _activityManager.CreateAsync(new ActivityLog(
+                string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name),
+                "UserDownloadingContent",
+                User.GetUserId())
             {
-                IncludeItemTypes = new[] { itemKind },
-                Limit = 0,
-                Recursive = true,
-                IsVirtualItem = false,
-                IsFavorite = isFavorite,
-                DtoOptions = new DtoOptions(false)
-                {
-                    EnableImages = false
-                }
-            };
-
-            return _libraryManager.GetItemsResult(query).TotalRecordCount;
+                ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()),
+            }).ConfigureAwait(false);
         }
-
-        private BaseItem? TranslateParentItem(BaseItem item, User user)
+        catch
         {
-            return item.GetParent() is AggregateFolder
-                ? _libraryManager.GetUserRootFolder().GetChildren(user, true)
-                    .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path))
-                : item;
+            // Logged at lower levels
         }
+    }
 
-        private async Task LogDownloadAsync(BaseItem item, User user)
+    private static string[] GetRepresentativeItemTypes(string? contentType)
+    {
+        return contentType switch
         {
-            try
-            {
-                await _activityManager.CreateAsync(new ActivityLog(
-                    string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name),
-                    "UserDownloadingContent",
-                    User.GetUserId())
-                {
-                    ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()),
-                }).ConfigureAwait(false);
-            }
-            catch
-            {
-                // Logged at lower levels
-            }
-        }
+            CollectionType.BoxSets => new[] { "BoxSet" },
+            CollectionType.Playlists => new[] { "Playlist" },
+            CollectionType.Movies => new[] { "Movie" },
+            CollectionType.TvShows => new[] { "Series", "Season", "Episode" },
+            CollectionType.Books => new[] { "Book" },
+            CollectionType.Music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" },
+            CollectionType.HomeVideos => new[] { "Video", "Photo" },
+            CollectionType.Photos => new[] { "Video", "Photo" },
+            CollectionType.MusicVideos => new[] { "MusicVideo" },
+            _ => new[] { "Series", "Season", "Episode", "Movie" }
+        };
+    }
 
-        private static string[] GetRepresentativeItemTypes(string? contentType)
+    private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary)
+    {
+        if (isNewLibrary)
         {
-            return contentType switch
-            {
-                CollectionType.BoxSets => new[] { "BoxSet" },
-                CollectionType.Playlists => new[] { "Playlist" },
-                CollectionType.Movies => new[] { "Movie" },
-                CollectionType.TvShows => new[] { "Series", "Season", "Episode" },
-                CollectionType.Books => new[] { "Book" },
-                CollectionType.Music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" },
-                CollectionType.HomeVideos => new[] { "Video", "Photo" },
-                CollectionType.Photos => new[] { "Video", "Photo" },
-                CollectionType.MusicVideos => new[] { "MusicVideo" },
-                _ => new[] { "Series", "Season", "Episode", "Movie" }
-            };
-        }
-
-        private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary)
-        {
-            if (isNewLibrary)
-            {
-                return false;
-            }
+            return false;
+        }
 
-            var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions
-                .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparison.OrdinalIgnoreCase))
-                .ToArray();
+        var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions
+            .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparison.OrdinalIgnoreCase))
+            .ToArray();
 
-            return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparison.OrdinalIgnoreCase));
-        }
+        return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparison.OrdinalIgnoreCase));
+    }
 
-        private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary)
+    private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary)
+    {
+        if (isNewLibrary)
         {
-            if (isNewLibrary)
+            if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase))
             {
-                if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase))
-                {
-                    return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase)
-                         || string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase)
-                         || string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase));
-                }
-
-                return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase)
-                   || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase)
-                   || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase);
+                return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase)
+                     || string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase)
+                     || string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase));
             }
 
-            var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions
-                .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
-                .ToArray();
-
-            return metadataOptions.Length == 0
-               || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase));
+            return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase)
+               || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase)
+               || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase);
         }
 
-        private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary)
+        var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions
+            .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+            .ToArray();
+
+        return metadataOptions.Length == 0
+           || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase));
+    }
+
+    private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary)
+    {
+        if (isNewLibrary)
         {
-            if (isNewLibrary)
+            if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase))
             {
-                if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase))
-                {
-                    return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase)
-                           && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase)
-                           && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase)
-                           && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase);
-                }
-
-                return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase)
-                       || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase)
-                       || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase)
-                       || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase);
+                return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase)
+                       && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase)
+                       && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase)
+                       && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase);
             }
 
-            var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions
-                .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
-                .ToArray();
+            return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase)
+                   || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase)
+                   || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase)
+                   || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase);
+        }
 
-            if (metadataOptions.Length == 0)
-            {
-                return true;
-            }
+        var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions
+            .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+            .ToArray();
 
-            return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase));
+        if (metadataOptions.Length == 0)
+        {
+            return true;
         }
+
+        return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase));
     }
 }

+ 254 - 255
Jellyfin.Api/Controllers/LibraryStructureController.cs

@@ -20,308 +20,307 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The library structure controller.
+/// </summary>
+[Route("Library/VirtualFolders")]
+[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+public class LibraryStructureController : BaseJellyfinApiController
 {
+    private readonly IServerApplicationPaths _appPaths;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILibraryMonitor _libraryMonitor;
+
     /// <summary>
-    /// The library structure controller.
+    /// Initializes a new instance of the <see cref="LibraryStructureController"/> class.
     /// </summary>
-    [Route("Library/VirtualFolders")]
-    [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
-    public class LibraryStructureController : BaseJellyfinApiController
+    /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param>
+    public LibraryStructureController(
+        IServerConfigurationManager serverConfigurationManager,
+        ILibraryManager libraryManager,
+        ILibraryMonitor libraryMonitor)
     {
-        private readonly IServerApplicationPaths _appPaths;
-        private readonly ILibraryManager _libraryManager;
-        private readonly ILibraryMonitor _libraryMonitor;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="LibraryStructureController"/> class.
-        /// </summary>
-        /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param>
-        public LibraryStructureController(
-            IServerConfigurationManager serverConfigurationManager,
-            ILibraryManager libraryManager,
-            ILibraryMonitor libraryMonitor)
-        {
-            _appPaths = serverConfigurationManager.ApplicationPaths;
-            _libraryManager = libraryManager;
-            _libraryMonitor = libraryMonitor;
-        }
+        _appPaths = serverConfigurationManager.ApplicationPaths;
+        _libraryManager = libraryManager;
+        _libraryMonitor = libraryMonitor;
+    }
+
+    /// <summary>
+    /// Gets all virtual folders.
+    /// </summary>
+    /// <response code="200">Virtual folders retrieved.</response>
+    /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders()
+    {
+        return _libraryManager.GetVirtualFolders(true);
+    }
+
+    /// <summary>
+    /// Adds a virtual folder.
+    /// </summary>
+    /// <param name="name">The name of the virtual folder.</param>
+    /// <param name="collectionType">The type of the collection.</param>
+    /// <param name="paths">The paths of the virtual folder.</param>
+    /// <param name="libraryOptionsDto">The library options.</param>
+    /// <param name="refreshLibrary">Whether to refresh the library.</param>
+    /// <response code="204">Folder added.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> AddVirtualFolder(
+        [FromQuery] string? name,
+        [FromQuery] CollectionTypeOptions? collectionType,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths,
+        [FromBody] AddVirtualFolderDto? libraryOptionsDto,
+        [FromQuery] bool refreshLibrary = false)
+    {
+        var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions();
 
-        /// <summary>
-        /// Gets all virtual folders.
-        /// </summary>
-        /// <response code="200">Virtual folders retrieved.</response>
-        /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders()
+        if (paths is not null && paths.Length > 0)
         {
-            return _libraryManager.GetVirtualFolders(true);
+            libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo(i)).ToArray();
         }
 
-        /// <summary>
-        /// Adds a virtual folder.
-        /// </summary>
-        /// <param name="name">The name of the virtual folder.</param>
-        /// <param name="collectionType">The type of the collection.</param>
-        /// <param name="paths">The paths of the virtual folder.</param>
-        /// <param name="libraryOptionsDto">The library options.</param>
-        /// <param name="refreshLibrary">Whether to refresh the library.</param>
-        /// <response code="204">Folder added.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> AddVirtualFolder(
-            [FromQuery] string? name,
-            [FromQuery] CollectionTypeOptions? collectionType,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths,
-            [FromBody] AddVirtualFolderDto? libraryOptionsDto,
-            [FromQuery] bool refreshLibrary = false)
-        {
-            var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions();
+        await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false);
 
-            if (paths is not null && paths.Length > 0)
-            {
-                libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo(i)).ToArray();
-            }
+        return NoContent();
+    }
 
-            await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false);
+    /// <summary>
+    /// Removes a virtual folder.
+    /// </summary>
+    /// <param name="name">The name of the folder.</param>
+    /// <param name="refreshLibrary">Whether to refresh the library.</param>
+    /// <response code="204">Folder removed.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> RemoveVirtualFolder(
+        [FromQuery] string? name,
+        [FromQuery] bool refreshLibrary = false)
+    {
+        await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            return NoContent();
+    /// <summary>
+    /// Renames a virtual folder.
+    /// </summary>
+    /// <param name="name">The name of the virtual folder.</param>
+    /// <param name="newName">The new name.</param>
+    /// <param name="refreshLibrary">Whether to refresh the library.</param>
+    /// <response code="204">Folder renamed.</response>
+    /// <response code="404">Library doesn't exist.</response>
+    /// <response code="409">Library already exists.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
+    /// <exception cref="ArgumentNullException">The new name may not be null.</exception>
+    [HttpPost("Name")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesResponseType(StatusCodes.Status409Conflict)]
+    public ActionResult RenameVirtualFolder(
+        [FromQuery] string? name,
+        [FromQuery] string? newName,
+        [FromQuery] bool refreshLibrary = false)
+    {
+        if (string.IsNullOrWhiteSpace(name))
+        {
+            throw new ArgumentNullException(nameof(name));
         }
 
-        /// <summary>
-        /// Removes a virtual folder.
-        /// </summary>
-        /// <param name="name">The name of the folder.</param>
-        /// <param name="refreshLibrary">Whether to refresh the library.</param>
-        /// <response code="204">Folder removed.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> RemoveVirtualFolder(
-            [FromQuery] string? name,
-            [FromQuery] bool refreshLibrary = false)
+        if (string.IsNullOrWhiteSpace(newName))
         {
-            await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false);
-            return NoContent();
+            throw new ArgumentNullException(nameof(newName));
         }
 
-        /// <summary>
-        /// Renames a virtual folder.
-        /// </summary>
-        /// <param name="name">The name of the virtual folder.</param>
-        /// <param name="newName">The new name.</param>
-        /// <param name="refreshLibrary">Whether to refresh the library.</param>
-        /// <response code="204">Folder renamed.</response>
-        /// <response code="404">Library doesn't exist.</response>
-        /// <response code="409">Library already exists.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
-        /// <exception cref="ArgumentNullException">The new name may not be null.</exception>
-        [HttpPost("Name")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesResponseType(StatusCodes.Status409Conflict)]
-        public ActionResult RenameVirtualFolder(
-            [FromQuery] string? name,
-            [FromQuery] string? newName,
-            [FromQuery] bool refreshLibrary = false)
-        {
-            if (string.IsNullOrWhiteSpace(name))
-            {
-                throw new ArgumentNullException(nameof(name));
-            }
+        var rootFolderPath = _appPaths.DefaultUserViewsPath;
 
-            if (string.IsNullOrWhiteSpace(newName))
-            {
-                throw new ArgumentNullException(nameof(newName));
-            }
+        var currentPath = Path.Combine(rootFolderPath, name);
+        var newPath = Path.Combine(rootFolderPath, newName);
 
-            var rootFolderPath = _appPaths.DefaultUserViewsPath;
+        if (!Directory.Exists(currentPath))
+        {
+            return NotFound("The media collection does not exist.");
+        }
 
-            var currentPath = Path.Combine(rootFolderPath, name);
-            var newPath = Path.Combine(rootFolderPath, newName);
+        if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath))
+        {
+            return Conflict($"The media library already exists at {newPath}.");
+        }
 
-            if (!Directory.Exists(currentPath))
-            {
-                return NotFound("The media collection does not exist.");
-            }
+        _libraryMonitor.Stop();
 
-            if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath))
+        try
+        {
+            // Changing capitalization. Handle windows case insensitivity
+            if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase))
             {
-                return Conflict($"The media library already exists at {newPath}.");
+                var tempPath = Path.Combine(
+                    rootFolderPath,
+                    Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
+                Directory.Move(currentPath, tempPath);
+                currentPath = tempPath;
             }
 
-            _libraryMonitor.Stop();
+            Directory.Move(currentPath, newPath);
+        }
+        finally
+        {
+            CollectionFolder.OnCollectionFolderChange();
 
-            try
+            Task.Run(async () =>
             {
-                // Changing capitalization. Handle windows case insensitivity
-                if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase))
+                // No need to start if scanning the library because it will handle it
+                if (refreshLibrary)
                 {
-                    var tempPath = Path.Combine(
-                        rootFolderPath,
-                        Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
-                    Directory.Move(currentPath, tempPath);
-                    currentPath = tempPath;
+                    await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
                 }
-
-                Directory.Move(currentPath, newPath);
-            }
-            finally
-            {
-                CollectionFolder.OnCollectionFolderChange();
-
-                Task.Run(async () =>
+                else
                 {
-                    // No need to start if scanning the library because it will handle it
-                    if (refreshLibrary)
-                    {
-                        await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
-                    }
-                    else
-                    {
-                        // Need to add a delay here or directory watchers may still pick up the changes
-                        // Have to block here to allow exceptions to bubble
-                        await Task.Delay(1000).ConfigureAwait(false);
-                        _libraryMonitor.Start();
-                    }
-                });
-            }
-
-            return NoContent();
+                    // Need to add a delay here or directory watchers may still pick up the changes
+                    // Have to block here to allow exceptions to bubble
+                    await Task.Delay(1000).ConfigureAwait(false);
+                    _libraryMonitor.Start();
+                }
+            });
         }
 
-        /// <summary>
-        /// Add a media path to a library.
-        /// </summary>
-        /// <param name="mediaPathDto">The media path dto.</param>
-        /// <param name="refreshLibrary">Whether to refresh the library.</param>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        /// <response code="204">Media path added.</response>
-        /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
-        [HttpPost("Paths")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult AddMediaPath(
-            [FromBody, Required] MediaPathDto mediaPathDto,
-            [FromQuery] bool refreshLibrary = false)
-        {
-            _libraryMonitor.Stop();
+        return NoContent();
+    }
 
-            try
-            {
-                var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException("PathInfo and Path can't both be null."));
+    /// <summary>
+    /// Add a media path to a library.
+    /// </summary>
+    /// <param name="mediaPathDto">The media path dto.</param>
+    /// <param name="refreshLibrary">Whether to refresh the library.</param>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    /// <response code="204">Media path added.</response>
+    /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
+    [HttpPost("Paths")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult AddMediaPath(
+        [FromBody, Required] MediaPathDto mediaPathDto,
+        [FromQuery] bool refreshLibrary = false)
+    {
+        _libraryMonitor.Stop();
 
-                _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath);
-            }
-            finally
-            {
-                Task.Run(async () =>
-                {
-                    // No need to start if scanning the library because it will handle it
-                    if (refreshLibrary)
-                    {
-                        await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
-                    }
-                    else
-                    {
-                        // Need to add a delay here or directory watchers may still pick up the changes
-                        // Have to block here to allow exceptions to bubble
-                        await Task.Delay(1000).ConfigureAwait(false);
-                        _libraryMonitor.Start();
-                    }
-                });
-            }
+        try
+        {
+            var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException("PathInfo and Path can't both be null."));
 
-            return NoContent();
+            _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath);
         }
-
-        /// <summary>
-        /// Updates a media path.
-        /// </summary>
-        /// <param name="mediaPathRequestDto">The name of the library and path infos.</param>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        /// <response code="204">Media path updated.</response>
-        /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
-        [HttpPost("Paths/Update")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult UpdateMediaPath([FromBody, Required] UpdateMediaPathRequestDto mediaPathRequestDto)
+        finally
         {
-            if (string.IsNullOrWhiteSpace(mediaPathRequestDto.Name))
+            Task.Run(async () =>
             {
-                throw new ArgumentNullException(nameof(mediaPathRequestDto), "Name must not be null or empty");
-            }
+                // No need to start if scanning the library because it will handle it
+                if (refreshLibrary)
+                {
+                    await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
+                }
+                else
+                {
+                    // Need to add a delay here or directory watchers may still pick up the changes
+                    // Have to block here to allow exceptions to bubble
+                    await Task.Delay(1000).ConfigureAwait(false);
+                    _libraryMonitor.Start();
+                }
+            });
+        }
+
+        return NoContent();
+    }
 
-            _libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.PathInfo);
-            return NoContent();
+    /// <summary>
+    /// Updates a media path.
+    /// </summary>
+    /// <param name="mediaPathRequestDto">The name of the library and path infos.</param>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    /// <response code="204">Media path updated.</response>
+    /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
+    [HttpPost("Paths/Update")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult UpdateMediaPath([FromBody, Required] UpdateMediaPathRequestDto mediaPathRequestDto)
+    {
+        if (string.IsNullOrWhiteSpace(mediaPathRequestDto.Name))
+        {
+            throw new ArgumentNullException(nameof(mediaPathRequestDto), "Name must not be null or empty");
         }
 
-        /// <summary>
-        /// Remove a media path.
-        /// </summary>
-        /// <param name="name">The name of the library.</param>
-        /// <param name="path">The path to remove.</param>
-        /// <param name="refreshLibrary">Whether to refresh the library.</param>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        /// <response code="204">Media path removed.</response>
-        /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
-        [HttpDelete("Paths")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult RemoveMediaPath(
-            [FromQuery] string? name,
-            [FromQuery] string? path,
-            [FromQuery] bool refreshLibrary = false)
+        _libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.PathInfo);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Remove a media path.
+    /// </summary>
+    /// <param name="name">The name of the library.</param>
+    /// <param name="path">The path to remove.</param>
+    /// <param name="refreshLibrary">Whether to refresh the library.</param>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    /// <response code="204">Media path removed.</response>
+    /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
+    [HttpDelete("Paths")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult RemoveMediaPath(
+        [FromQuery] string? name,
+        [FromQuery] string? path,
+        [FromQuery] bool refreshLibrary = false)
+    {
+        if (string.IsNullOrWhiteSpace(name))
         {
-            if (string.IsNullOrWhiteSpace(name))
-            {
-                throw new ArgumentNullException(nameof(name));
-            }
+            throw new ArgumentNullException(nameof(name));
+        }
 
-            _libraryMonitor.Stop();
+        _libraryMonitor.Stop();
 
-            try
-            {
-                _libraryManager.RemoveMediaPath(name, path);
-            }
-            finally
+        try
+        {
+            _libraryManager.RemoveMediaPath(name, path);
+        }
+        finally
+        {
+            Task.Run(async () =>
             {
-                Task.Run(async () =>
+                // No need to start if scanning the library because it will handle it
+                if (refreshLibrary)
                 {
-                    // No need to start if scanning the library because it will handle it
-                    if (refreshLibrary)
-                    {
-                        await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
-                    }
-                    else
-                    {
-                        // Need to add a delay here or directory watchers may still pick up the changes
-                        // Have to block here to allow exceptions to bubble
-                        await Task.Delay(1000).ConfigureAwait(false);
-                        _libraryMonitor.Start();
-                    }
-                });
-            }
-
-            return NoContent();
+                    await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
+                }
+                else
+                {
+                    // Need to add a delay here or directory watchers may still pick up the changes
+                    // Have to block here to allow exceptions to bubble
+                    await Task.Delay(1000).ConfigureAwait(false);
+                    _libraryMonitor.Start();
+                }
+            });
         }
 
-        /// <summary>
-        /// Update library options.
-        /// </summary>
-        /// <param name="request">The library name and options.</param>
-        /// <response code="204">Library updated.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("LibraryOptions")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult UpdateLibraryOptions(
-            [FromBody] UpdateLibraryOptionsDto request)
-        {
-            var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id);
+        return NoContent();
+    }
 
-            collectionFolder.UpdateLibraryOptions(request.LibraryOptions);
-            return NoContent();
-        }
+    /// <summary>
+    /// Update library options.
+    /// </summary>
+    /// <param name="request">The library name and options.</param>
+    /// <response code="204">Library updated.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("LibraryOptions")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult UpdateLibraryOptions(
+        [FromBody] UpdateLibraryOptionsDto request)
+    {
+        var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id);
+
+        collectionFolder.UpdateLibraryOptions(request.LibraryOptions);
+        return NoContent();
     }
 }

+ 1102 - 1103
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -35,1200 +35,1199 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Live tv controller.
+/// </summary>
+public class LiveTvController : BaseJellyfinApiController
 {
+    private readonly ILiveTvManager _liveTvManager;
+    private readonly IUserManager _userManager;
+    private readonly IHttpClientFactory _httpClientFactory;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IDtoService _dtoService;
+    private readonly IMediaSourceManager _mediaSourceManager;
+    private readonly IConfigurationManager _configurationManager;
+    private readonly TranscodingJobHelper _transcodingJobHelper;
+    private readonly ISessionManager _sessionManager;
+
     /// <summary>
-    /// Live tv controller.
+    /// Initializes a new instance of the <see cref="LiveTvController"/> class.
     /// </summary>
-    public class LiveTvController : BaseJellyfinApiController
+    /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+    /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param>
+    /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
+    public LiveTvController(
+        ILiveTvManager liveTvManager,
+        IUserManager userManager,
+        IHttpClientFactory httpClientFactory,
+        ILibraryManager libraryManager,
+        IDtoService dtoService,
+        IMediaSourceManager mediaSourceManager,
+        IConfigurationManager configurationManager,
+        TranscodingJobHelper transcodingJobHelper,
+        ISessionManager sessionManager)
     {
-        private readonly ILiveTvManager _liveTvManager;
-        private readonly IUserManager _userManager;
-        private readonly IHttpClientFactory _httpClientFactory;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-        private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IConfigurationManager _configurationManager;
-        private readonly TranscodingJobHelper _transcodingJobHelper;
-        private readonly ISessionManager _sessionManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="LiveTvController"/> class.
-        /// </summary>
-        /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
-        /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param>
-        /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
-        public LiveTvController(
-            ILiveTvManager liveTvManager,
-            IUserManager userManager,
-            IHttpClientFactory httpClientFactory,
-            ILibraryManager libraryManager,
-            IDtoService dtoService,
-            IMediaSourceManager mediaSourceManager,
-            IConfigurationManager configurationManager,
-            TranscodingJobHelper transcodingJobHelper,
-            ISessionManager sessionManager)
-        {
-            _liveTvManager = liveTvManager;
-            _userManager = userManager;
-            _httpClientFactory = httpClientFactory;
-            _libraryManager = libraryManager;
-            _dtoService = dtoService;
-            _mediaSourceManager = mediaSourceManager;
-            _configurationManager = configurationManager;
-            _transcodingJobHelper = transcodingJobHelper;
-            _sessionManager = sessionManager;
-        }
+        _liveTvManager = liveTvManager;
+        _userManager = userManager;
+        _httpClientFactory = httpClientFactory;
+        _libraryManager = libraryManager;
+        _dtoService = dtoService;
+        _mediaSourceManager = mediaSourceManager;
+        _configurationManager = configurationManager;
+        _transcodingJobHelper = transcodingJobHelper;
+        _sessionManager = sessionManager;
+    }
 
-        /// <summary>
-        /// Gets available live tv services.
-        /// </summary>
-        /// <response code="200">Available live tv services returned.</response>
-        /// <returns>
-        /// An <see cref="OkResult"/> containing the available live tv services.
-        /// </returns>
-        [HttpGet("Info")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public ActionResult<LiveTvInfo> GetLiveTvInfo()
-        {
-            return _liveTvManager.GetLiveTvInfo(CancellationToken.None);
-        }
+    /// <summary>
+    /// Gets available live tv services.
+    /// </summary>
+    /// <response code="200">Available live tv services returned.</response>
+    /// <returns>
+    /// An <see cref="OkResult"/> containing the available live tv services.
+    /// </returns>
+    [HttpGet("Info")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public ActionResult<LiveTvInfo> GetLiveTvInfo()
+    {
+        return _liveTvManager.GetLiveTvInfo(CancellationToken.None);
+    }
 
-        /// <summary>
-        /// Gets available live tv channels.
-        /// </summary>
-        /// <param name="type">Optional. Filter by channel type.</param>
-        /// <param name="userId">Optional. Filter by user and attach user data.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="isMovie">Optional. Filter for movies.</param>
-        /// <param name="isSeries">Optional. Filter for series.</param>
-        /// <param name="isNews">Optional. Filter for news.</param>
-        /// <param name="isKids">Optional. Filter for kids.</param>
-        /// <param name="isSports">Optional. Filter for sports.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="isFavorite">Optional. Filter by channels that are favorites, or not.</param>
-        /// <param name="isLiked">Optional. Filter by channels that are liked, or not.</param>
-        /// <param name="isDisliked">Optional. Filter by channels that are disliked, or not.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">"Optional. The image types to include in the output.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="sortBy">Optional. Key to sort by.</param>
-        /// <param name="sortOrder">Optional. Sort order.</param>
-        /// <param name="enableFavoriteSorting">Optional. Incorporate favorite and like status into channel sorting.</param>
-        /// <param name="addCurrentProgram">Optional. Adds current program info to each channel.</param>
-        /// <response code="200">Available live tv channels returned.</response>
-        /// <returns>
-        /// An <see cref="OkResult"/> containing the resulting available live tv channels.
-        /// </returns>
-        [HttpGet("Channels")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public ActionResult<QueryResult<BaseItemDto>> GetLiveTvChannels(
-            [FromQuery] ChannelType? type,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? startIndex,
-            [FromQuery] bool? isMovie,
-            [FromQuery] bool? isSeries,
-            [FromQuery] bool? isNews,
-            [FromQuery] bool? isKids,
-            [FromQuery] bool? isSports,
-            [FromQuery] int? limit,
-            [FromQuery] bool? isFavorite,
-            [FromQuery] bool? isLiked,
-            [FromQuery] bool? isDisliked,
-            [FromQuery] bool? enableImages,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableUserData,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery] SortOrder? sortOrder,
-            [FromQuery] bool enableFavoriteSorting = false,
-            [FromQuery] bool addCurrentProgram = true)
-        {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-
-            var channelResult = _liveTvManager.GetInternalChannels(
-                new LiveTvChannelQuery
-                {
-                    ChannelType = type,
-                    UserId = userId ?? Guid.Empty,
-                    StartIndex = startIndex,
-                    Limit = limit,
-                    IsFavorite = isFavorite,
-                    IsLiked = isLiked,
-                    IsDisliked = isDisliked,
-                    EnableFavoriteSorting = enableFavoriteSorting,
-                    IsMovie = isMovie,
-                    IsSeries = isSeries,
-                    IsNews = isNews,
-                    IsKids = isKids,
-                    IsSports = isSports,
-                    SortBy = sortBy,
-                    SortOrder = sortOrder ?? SortOrder.Ascending,
-                    AddCurrentProgram = addCurrentProgram
-                },
-                dtoOptions,
-                CancellationToken.None);
-
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-
-            var fieldsList = dtoOptions.Fields.ToList();
-            fieldsList.Remove(ItemFields.CanDelete);
-            fieldsList.Remove(ItemFields.CanDownload);
-            fieldsList.Remove(ItemFields.DisplayPreferencesId);
-            fieldsList.Remove(ItemFields.Etag);
-            dtoOptions.Fields = fieldsList.ToArray();
-            dtoOptions.AddCurrentProgram = addCurrentProgram;
-
-            var returnArray = _dtoService.GetBaseItemDtos(channelResult.Items, dtoOptions, user);
-            return new QueryResult<BaseItemDto>(
-                startIndex,
-                channelResult.TotalRecordCount,
-                returnArray);
-        }
+    /// <summary>
+    /// Gets available live tv channels.
+    /// </summary>
+    /// <param name="type">Optional. Filter by channel type.</param>
+    /// <param name="userId">Optional. Filter by user and attach user data.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="isMovie">Optional. Filter for movies.</param>
+    /// <param name="isSeries">Optional. Filter for series.</param>
+    /// <param name="isNews">Optional. Filter for news.</param>
+    /// <param name="isKids">Optional. Filter for kids.</param>
+    /// <param name="isSports">Optional. Filter for sports.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="isFavorite">Optional. Filter by channels that are favorites, or not.</param>
+    /// <param name="isLiked">Optional. Filter by channels that are liked, or not.</param>
+    /// <param name="isDisliked">Optional. Filter by channels that are disliked, or not.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">"Optional. The image types to include in the output.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="sortBy">Optional. Key to sort by.</param>
+    /// <param name="sortOrder">Optional. Sort order.</param>
+    /// <param name="enableFavoriteSorting">Optional. Incorporate favorite and like status into channel sorting.</param>
+    /// <param name="addCurrentProgram">Optional. Adds current program info to each channel.</param>
+    /// <response code="200">Available live tv channels returned.</response>
+    /// <returns>
+    /// An <see cref="OkResult"/> containing the resulting available live tv channels.
+    /// </returns>
+    [HttpGet("Channels")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public ActionResult<QueryResult<BaseItemDto>> GetLiveTvChannels(
+        [FromQuery] ChannelType? type,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? startIndex,
+        [FromQuery] bool? isMovie,
+        [FromQuery] bool? isSeries,
+        [FromQuery] bool? isNews,
+        [FromQuery] bool? isKids,
+        [FromQuery] bool? isSports,
+        [FromQuery] int? limit,
+        [FromQuery] bool? isFavorite,
+        [FromQuery] bool? isLiked,
+        [FromQuery] bool? isDisliked,
+        [FromQuery] bool? enableImages,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableUserData,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery] SortOrder? sortOrder,
+        [FromQuery] bool enableFavoriteSorting = false,
+        [FromQuery] bool addCurrentProgram = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-        /// <summary>
-        /// Gets a live tv channel.
-        /// </summary>
-        /// <param name="channelId">Channel id.</param>
-        /// <param name="userId">Optional. Attach user data.</param>
-        /// <response code="200">Live tv channel returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the live tv channel.</returns>
-        [HttpGet("Channels/{channelId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public ActionResult<BaseItemDto> GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var item = channelId.Equals(default)
-                ? _libraryManager.GetUserRootFolder()
-                : _libraryManager.GetItemById(channelId);
-
-            var dtoOptions = new DtoOptions()
-                .AddClientFields(User);
-            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
-        }
+        var channelResult = _liveTvManager.GetInternalChannels(
+            new LiveTvChannelQuery
+            {
+                ChannelType = type,
+                UserId = userId ?? Guid.Empty,
+                StartIndex = startIndex,
+                Limit = limit,
+                IsFavorite = isFavorite,
+                IsLiked = isLiked,
+                IsDisliked = isDisliked,
+                EnableFavoriteSorting = enableFavoriteSorting,
+                IsMovie = isMovie,
+                IsSeries = isSeries,
+                IsNews = isNews,
+                IsKids = isKids,
+                IsSports = isSports,
+                SortBy = sortBy,
+                SortOrder = sortOrder ?? SortOrder.Ascending,
+                AddCurrentProgram = addCurrentProgram
+            },
+            dtoOptions,
+            CancellationToken.None);
+
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+
+        var fieldsList = dtoOptions.Fields.ToList();
+        fieldsList.Remove(ItemFields.CanDelete);
+        fieldsList.Remove(ItemFields.CanDownload);
+        fieldsList.Remove(ItemFields.DisplayPreferencesId);
+        fieldsList.Remove(ItemFields.Etag);
+        dtoOptions.Fields = fieldsList.ToArray();
+        dtoOptions.AddCurrentProgram = addCurrentProgram;
+
+        var returnArray = _dtoService.GetBaseItemDtos(channelResult.Items, dtoOptions, user);
+        return new QueryResult<BaseItemDto>(
+            startIndex,
+            channelResult.TotalRecordCount,
+            returnArray);
+    }
 
-        /// <summary>
-        /// Gets live tv recordings.
-        /// </summary>
-        /// <param name="channelId">Optional. Filter by channel id.</param>
-        /// <param name="userId">Optional. Filter by user and attach user data.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="status">Optional. Filter by recording status.</param>
-        /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param>
-        /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="isMovie">Optional. Filter for movies.</param>
-        /// <param name="isSeries">Optional. Filter for series.</param>
-        /// <param name="isKids">Optional. Filter for kids.</param>
-        /// <param name="isSports">Optional. Filter for sports.</param>
-        /// <param name="isNews">Optional. Filter for news.</param>
-        /// <param name="isLibraryItem">Optional. Filter for is library item.</param>
-        /// <param name="enableTotalRecordCount">Optional. Return total record count.</param>
-        /// <response code="200">Live tv recordings returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns>
-        [HttpGet("Recordings")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public ActionResult<QueryResult<BaseItemDto>> GetRecordings(
-            [FromQuery] string? channelId,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] RecordingStatus? status,
-            [FromQuery] bool? isInProgress,
-            [FromQuery] string? seriesTimerId,
-            [FromQuery] bool? enableImages,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] bool? isMovie,
-            [FromQuery] bool? isSeries,
-            [FromQuery] bool? isKids,
-            [FromQuery] bool? isSports,
-            [FromQuery] bool? isNews,
-            [FromQuery] bool? isLibraryItem,
-            [FromQuery] bool enableTotalRecordCount = true)
-        {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-
-            return _liveTvManager.GetRecordings(
-                new RecordingQuery
-                {
-                    ChannelId = channelId,
-                    UserId = userId ?? Guid.Empty,
-                    StartIndex = startIndex,
-                    Limit = limit,
-                    Status = status,
-                    SeriesTimerId = seriesTimerId,
-                    IsInProgress = isInProgress,
-                    EnableTotalRecordCount = enableTotalRecordCount,
-                    IsMovie = isMovie,
-                    IsNews = isNews,
-                    IsSeries = isSeries,
-                    IsKids = isKids,
-                    IsSports = isSports,
-                    IsLibraryItem = isLibraryItem,
-                    Fields = fields,
-                    ImageTypeLimit = imageTypeLimit,
-                    EnableImages = enableImages
-                },
-                dtoOptions);
-        }
+    /// <summary>
+    /// Gets a live tv channel.
+    /// </summary>
+    /// <param name="channelId">Channel id.</param>
+    /// <param name="userId">Optional. Attach user data.</param>
+    /// <response code="200">Live tv channel returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the live tv channel.</returns>
+    [HttpGet("Channels/{channelId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public ActionResult<BaseItemDto> GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var item = channelId.Equals(default)
+            ? _libraryManager.GetUserRootFolder()
+            : _libraryManager.GetItemById(channelId);
+
+        var dtoOptions = new DtoOptions()
+            .AddClientFields(User);
+        return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+    }
 
-        /// <summary>
-        /// Gets live tv recording series.
-        /// </summary>
-        /// <param name="channelId">Optional. Filter by channel id.</param>
-        /// <param name="userId">Optional. Filter by user and attach user data.</param>
-        /// <param name="groupId">Optional. Filter by recording group.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="status">Optional. Filter by recording status.</param>
-        /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param>
-        /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="enableTotalRecordCount">Optional. Return total record count.</param>
-        /// <response code="200">Live tv recordings returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns>
-        [HttpGet("Recordings/Series")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [Obsolete("This endpoint is obsolete.")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")]
-        public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries(
-            [FromQuery] string? channelId,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? groupId,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] RecordingStatus? status,
-            [FromQuery] bool? isInProgress,
-            [FromQuery] string? seriesTimerId,
-            [FromQuery] bool? enableImages,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] bool enableTotalRecordCount = true)
-        {
-            return new QueryResult<BaseItemDto>();
-        }
+    /// <summary>
+    /// Gets live tv recordings.
+    /// </summary>
+    /// <param name="channelId">Optional. Filter by channel id.</param>
+    /// <param name="userId">Optional. Filter by user and attach user data.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="status">Optional. Filter by recording status.</param>
+    /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param>
+    /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="isMovie">Optional. Filter for movies.</param>
+    /// <param name="isSeries">Optional. Filter for series.</param>
+    /// <param name="isKids">Optional. Filter for kids.</param>
+    /// <param name="isSports">Optional. Filter for sports.</param>
+    /// <param name="isNews">Optional. Filter for news.</param>
+    /// <param name="isLibraryItem">Optional. Filter for is library item.</param>
+    /// <param name="enableTotalRecordCount">Optional. Return total record count.</param>
+    /// <response code="200">Live tv recordings returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns>
+    [HttpGet("Recordings")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public ActionResult<QueryResult<BaseItemDto>> GetRecordings(
+        [FromQuery] string? channelId,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] RecordingStatus? status,
+        [FromQuery] bool? isInProgress,
+        [FromQuery] string? seriesTimerId,
+        [FromQuery] bool? enableImages,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] bool? isMovie,
+        [FromQuery] bool? isSeries,
+        [FromQuery] bool? isKids,
+        [FromQuery] bool? isSports,
+        [FromQuery] bool? isNews,
+        [FromQuery] bool? isLibraryItem,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-        /// <summary>
-        /// Gets live tv recording groups.
-        /// </summary>
-        /// <param name="userId">Optional. Filter by user and attach user data.</param>
-        /// <response code="200">Recording groups returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the recording groups.</returns>
-        [HttpGet("Recordings/Groups")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [Obsolete("This endpoint is obsolete.")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
-        public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId)
-        {
-            return new QueryResult<BaseItemDto>();
-        }
+        return _liveTvManager.GetRecordings(
+            new RecordingQuery
+            {
+                ChannelId = channelId,
+                UserId = userId ?? Guid.Empty,
+                StartIndex = startIndex,
+                Limit = limit,
+                Status = status,
+                SeriesTimerId = seriesTimerId,
+                IsInProgress = isInProgress,
+                EnableTotalRecordCount = enableTotalRecordCount,
+                IsMovie = isMovie,
+                IsNews = isNews,
+                IsSeries = isSeries,
+                IsKids = isKids,
+                IsSports = isSports,
+                IsLibraryItem = isLibraryItem,
+                Fields = fields,
+                ImageTypeLimit = imageTypeLimit,
+                EnableImages = enableImages
+            },
+            dtoOptions);
+    }
 
-        /// <summary>
-        /// Gets recording folders.
-        /// </summary>
-        /// <param name="userId">Optional. Filter by user and attach user data.</param>
-        /// <response code="200">Recording folders returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the recording folders.</returns>
-        [HttpGet("Recordings/Folders")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public ActionResult<QueryResult<BaseItemDto>> GetRecordingFolders([FromQuery] Guid? userId)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var folders = _liveTvManager.GetRecordingFolders(user);
+    /// <summary>
+    /// Gets live tv recording series.
+    /// </summary>
+    /// <param name="channelId">Optional. Filter by channel id.</param>
+    /// <param name="userId">Optional. Filter by user and attach user data.</param>
+    /// <param name="groupId">Optional. Filter by recording group.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="status">Optional. Filter by recording status.</param>
+    /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param>
+    /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="enableTotalRecordCount">Optional. Return total record count.</param>
+    /// <response code="200">Live tv recordings returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns>
+    [HttpGet("Recordings/Series")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [Obsolete("This endpoint is obsolete.")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")]
+    public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries(
+        [FromQuery] string? channelId,
+        [FromQuery] Guid? userId,
+        [FromQuery] string? groupId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] RecordingStatus? status,
+        [FromQuery] bool? isInProgress,
+        [FromQuery] string? seriesTimerId,
+        [FromQuery] bool? enableImages,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        return new QueryResult<BaseItemDto>();
+    }
 
-            var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user);
+    /// <summary>
+    /// Gets live tv recording groups.
+    /// </summary>
+    /// <param name="userId">Optional. Filter by user and attach user data.</param>
+    /// <response code="200">Recording groups returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the recording groups.</returns>
+    [HttpGet("Recordings/Groups")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [Obsolete("This endpoint is obsolete.")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
+    public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId)
+    {
+        return new QueryResult<BaseItemDto>();
+    }
 
-            return new QueryResult<BaseItemDto>(returnArray);
-        }
+    /// <summary>
+    /// Gets recording folders.
+    /// </summary>
+    /// <param name="userId">Optional. Filter by user and attach user data.</param>
+    /// <response code="200">Recording folders returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the recording folders.</returns>
+    [HttpGet("Recordings/Folders")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public ActionResult<QueryResult<BaseItemDto>> GetRecordingFolders([FromQuery] Guid? userId)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var folders = _liveTvManager.GetRecordingFolders(user);
 
-        /// <summary>
-        /// Gets a live tv recording.
-        /// </summary>
-        /// <param name="recordingId">Recording id.</param>
-        /// <param name="userId">Optional. Attach user data.</param>
-        /// <response code="200">Recording returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the live tv recording.</returns>
-        [HttpGet("Recordings/{recordingId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public ActionResult<BaseItemDto> GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var item = recordingId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId);
+        var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user);
 
-            var dtoOptions = new DtoOptions()
-                .AddClientFields(User);
+        return new QueryResult<BaseItemDto>(returnArray);
+    }
 
-            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
-        }
+    /// <summary>
+    /// Gets a live tv recording.
+    /// </summary>
+    /// <param name="recordingId">Recording id.</param>
+    /// <param name="userId">Optional. Attach user data.</param>
+    /// <response code="200">Recording returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the live tv recording.</returns>
+    [HttpGet("Recordings/{recordingId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public ActionResult<BaseItemDto> GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var item = recordingId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId);
 
-        /// <summary>
-        /// Resets a tv tuner.
-        /// </summary>
-        /// <param name="tunerId">Tuner id.</param>
-        /// <response code="204">Tuner reset.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Tuners/{tunerId}/Reset")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
-        {
-            await AssertUserCanManageLiveTv().ConfigureAwait(false);
-            await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
-            return NoContent();
-        }
+        var dtoOptions = new DtoOptions()
+            .AddClientFields(User);
 
-        /// <summary>
-        /// Gets a timer.
-        /// </summary>
-        /// <param name="timerId">Timer id.</param>
-        /// <response code="200">Timer returned.</response>
-        /// <returns>
-        /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the timer.
-        /// </returns>
-        [HttpGet("Timers/{timerId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public async Task<ActionResult<TimerInfoDto>> GetTimer([FromRoute, Required] string timerId)
-        {
-            return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false);
-        }
+        return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+    }
 
-        /// <summary>
-        /// Gets the default values for a new timer.
-        /// </summary>
-        /// <param name="programId">Optional. To attach default values based on a program.</param>
-        /// <response code="200">Default values returned.</response>
-        /// <returns>
-        /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the default values for a timer.
-        /// </returns>
-        [HttpGet("Timers/Defaults")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public async Task<ActionResult<SeriesTimerInfoDto>> GetDefaultTimer([FromQuery] string? programId)
-        {
-            return string.IsNullOrEmpty(programId)
-                ? await _liveTvManager.GetNewTimerDefaults(CancellationToken.None).ConfigureAwait(false)
-                : await _liveTvManager.GetNewTimerDefaults(programId, CancellationToken.None).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Resets a tv tuner.
+    /// </summary>
+    /// <param name="tunerId">Tuner id.</param>
+    /// <response code="204">Tuner reset.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Tuners/{tunerId}/Reset")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
+    {
+        await AssertUserCanManageLiveTv().ConfigureAwait(false);
+        await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Gets the live tv timers.
-        /// </summary>
-        /// <param name="channelId">Optional. Filter by channel id.</param>
-        /// <param name="seriesTimerId">Optional. Filter by timers belonging to a series timer.</param>
-        /// <param name="isActive">Optional. Filter by timers that are active.</param>
-        /// <param name="isScheduled">Optional. Filter by timers that are scheduled.</param>
-        /// <returns>
-        /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the live tv timers.
-        /// </returns>
-        [HttpGet("Timers")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public async Task<ActionResult<QueryResult<TimerInfoDto>>> GetTimers(
-            [FromQuery] string? channelId,
-            [FromQuery] string? seriesTimerId,
-            [FromQuery] bool? isActive,
-            [FromQuery] bool? isScheduled)
-        {
-            return await _liveTvManager.GetTimers(
-                new TimerQuery
-                {
-                    ChannelId = channelId,
-                    SeriesTimerId = seriesTimerId,
-                    IsActive = isActive,
-                    IsScheduled = isScheduled
-                },
-                CancellationToken.None).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Gets a timer.
+    /// </summary>
+    /// <param name="timerId">Timer id.</param>
+    /// <response code="200">Timer returned.</response>
+    /// <returns>
+    /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the timer.
+    /// </returns>
+    [HttpGet("Timers/{timerId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public async Task<ActionResult<TimerInfoDto>> GetTimer([FromRoute, Required] string timerId)
+    {
+        return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Gets available live tv epgs.
-        /// </summary>
-        /// <param name="channelIds">The channels to return guide information for.</param>
-        /// <param name="userId">Optional. Filter by user id.</param>
-        /// <param name="minStartDate">Optional. The minimum premiere start date.</param>
-        /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param>
-        /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param>
-        /// <param name="maxStartDate">Optional. The maximum premiere start date.</param>
-        /// <param name="minEndDate">Optional. The minimum premiere end date.</param>
-        /// <param name="maxEndDate">Optional. The maximum premiere end date.</param>
-        /// <param name="isMovie">Optional. Filter for movies.</param>
-        /// <param name="isSeries">Optional. Filter for series.</param>
-        /// <param name="isNews">Optional. Filter for news.</param>
-        /// <param name="isKids">Optional. Filter for kids.</param>
-        /// <param name="isSports">Optional. Filter for sports.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Name, StartDate.</param>
-        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
-        /// <param name="genres">The genres to return guide information for.</param>
-        /// <param name="genreIds">The genre ids to return guide information for.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="seriesTimerId">Optional. Filter by series timer id.</param>
-        /// <param name="librarySeriesId">Optional. Filter by library series id.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableTotalRecordCount">Retrieve total record count.</param>
-        /// <response code="200">Live tv epgs returned.</response>
-        /// <returns>
-        /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs.
-        /// </returns>
-        [HttpGet("Programs")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms(
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds,
-            [FromQuery] Guid? userId,
-            [FromQuery] DateTime? minStartDate,
-            [FromQuery] bool? hasAired,
-            [FromQuery] bool? isAiring,
-            [FromQuery] DateTime? maxStartDate,
-            [FromQuery] DateTime? minEndDate,
-            [FromQuery] DateTime? maxEndDate,
-            [FromQuery] bool? isMovie,
-            [FromQuery] bool? isSeries,
-            [FromQuery] bool? isNews,
-            [FromQuery] bool? isKids,
-            [FromQuery] bool? isSports,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
-            [FromQuery] bool? enableImages,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] string? seriesTimerId,
-            [FromQuery] Guid? librarySeriesId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool enableTotalRecordCount = true)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+    /// <summary>
+    /// Gets the default values for a new timer.
+    /// </summary>
+    /// <param name="programId">Optional. To attach default values based on a program.</param>
+    /// <response code="200">Default values returned.</response>
+    /// <returns>
+    /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the default values for a timer.
+    /// </returns>
+    [HttpGet("Timers/Defaults")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public async Task<ActionResult<SeriesTimerInfoDto>> GetDefaultTimer([FromQuery] string? programId)
+    {
+        return string.IsNullOrEmpty(programId)
+            ? await _liveTvManager.GetNewTimerDefaults(CancellationToken.None).ConfigureAwait(false)
+            : await _liveTvManager.GetNewTimerDefaults(programId, CancellationToken.None).ConfigureAwait(false);
+    }
 
-            var query = new InternalItemsQuery(user)
+    /// <summary>
+    /// Gets the live tv timers.
+    /// </summary>
+    /// <param name="channelId">Optional. Filter by channel id.</param>
+    /// <param name="seriesTimerId">Optional. Filter by timers belonging to a series timer.</param>
+    /// <param name="isActive">Optional. Filter by timers that are active.</param>
+    /// <param name="isScheduled">Optional. Filter by timers that are scheduled.</param>
+    /// <returns>
+    /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the live tv timers.
+    /// </returns>
+    [HttpGet("Timers")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public async Task<ActionResult<QueryResult<TimerInfoDto>>> GetTimers(
+        [FromQuery] string? channelId,
+        [FromQuery] string? seriesTimerId,
+        [FromQuery] bool? isActive,
+        [FromQuery] bool? isScheduled)
+    {
+        return await _liveTvManager.GetTimers(
+            new TimerQuery
             {
-                ChannelIds = channelIds,
-                HasAired = hasAired,
-                IsAiring = isAiring,
-                EnableTotalRecordCount = enableTotalRecordCount,
-                MinStartDate = minStartDate,
-                MinEndDate = minEndDate,
-                MaxStartDate = maxStartDate,
-                MaxEndDate = maxEndDate,
-                StartIndex = startIndex,
-                Limit = limit,
-                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
-                IsNews = isNews,
-                IsMovie = isMovie,
-                IsSeries = isSeries,
-                IsKids = isKids,
-                IsSports = isSports,
+                ChannelId = channelId,
                 SeriesTimerId = seriesTimerId,
-                Genres = genres,
-                GenreIds = genreIds
-            };
-
-            if (librarySeriesId.HasValue && !librarySeriesId.Equals(default))
-            {
-                query.IsSeries = true;
-
-                if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series)
-                {
-                    query.Name = series.Name;
-                }
-            }
+                IsActive = isActive,
+                IsScheduled = isScheduled
+            },
+            CancellationToken.None).ConfigureAwait(false);
+    }
 
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Gets available live tv epgs.
+    /// </summary>
+    /// <param name="channelIds">The channels to return guide information for.</param>
+    /// <param name="userId">Optional. Filter by user id.</param>
+    /// <param name="minStartDate">Optional. The minimum premiere start date.</param>
+    /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param>
+    /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param>
+    /// <param name="maxStartDate">Optional. The maximum premiere start date.</param>
+    /// <param name="minEndDate">Optional. The minimum premiere end date.</param>
+    /// <param name="maxEndDate">Optional. The maximum premiere end date.</param>
+    /// <param name="isMovie">Optional. Filter for movies.</param>
+    /// <param name="isSeries">Optional. Filter for series.</param>
+    /// <param name="isNews">Optional. Filter for news.</param>
+    /// <param name="isKids">Optional. Filter for kids.</param>
+    /// <param name="isSports">Optional. Filter for sports.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Name, StartDate.</param>
+    /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+    /// <param name="genres">The genres to return guide information for.</param>
+    /// <param name="genreIds">The genre ids to return guide information for.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="seriesTimerId">Optional. Filter by series timer id.</param>
+    /// <param name="librarySeriesId">Optional. Filter by library series id.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableTotalRecordCount">Retrieve total record count.</param>
+    /// <response code="200">Live tv epgs returned.</response>
+    /// <returns>
+    /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs.
+    /// </returns>
+    [HttpGet("Programs")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms(
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds,
+        [FromQuery] Guid? userId,
+        [FromQuery] DateTime? minStartDate,
+        [FromQuery] bool? hasAired,
+        [FromQuery] bool? isAiring,
+        [FromQuery] DateTime? maxStartDate,
+        [FromQuery] DateTime? minEndDate,
+        [FromQuery] DateTime? maxEndDate,
+        [FromQuery] bool? isMovie,
+        [FromQuery] bool? isSeries,
+        [FromQuery] bool? isNews,
+        [FromQuery] bool? isKids,
+        [FromQuery] bool? isSports,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery] bool? enableImages,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] string? seriesTimerId,
+        [FromQuery] Guid? librarySeriesId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-        /// <summary>
-        /// Gets available live tv epgs.
-        /// </summary>
-        /// <param name="body">Request body.</param>
-        /// <response code="200">Live tv epgs returned.</response>
-        /// <returns>
-        /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs.
-        /// </returns>
-        [HttpPost("Programs")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public async Task<ActionResult<QueryResult<BaseItemDto>>> GetPrograms([FromBody] GetProgramsDto body)
+        var query = new InternalItemsQuery(user)
         {
-            var user = body.UserId.Equals(default) ? null : _userManager.GetUserById(body.UserId);
+            ChannelIds = channelIds,
+            HasAired = hasAired,
+            IsAiring = isAiring,
+            EnableTotalRecordCount = enableTotalRecordCount,
+            MinStartDate = minStartDate,
+            MinEndDate = minEndDate,
+            MaxStartDate = maxStartDate,
+            MaxEndDate = maxEndDate,
+            StartIndex = startIndex,
+            Limit = limit,
+            OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
+            IsNews = isNews,
+            IsMovie = isMovie,
+            IsSeries = isSeries,
+            IsKids = isKids,
+            IsSports = isSports,
+            SeriesTimerId = seriesTimerId,
+            Genres = genres,
+            GenreIds = genreIds
+        };
+
+        if (librarySeriesId.HasValue && !librarySeriesId.Equals(default))
+        {
+            query.IsSeries = true;
 
-            var query = new InternalItemsQuery(user)
-            {
-                ChannelIds = body.ChannelIds,
-                HasAired = body.HasAired,
-                IsAiring = body.IsAiring,
-                EnableTotalRecordCount = body.EnableTotalRecordCount,
-                MinStartDate = body.MinStartDate,
-                MinEndDate = body.MinEndDate,
-                MaxStartDate = body.MaxStartDate,
-                MaxEndDate = body.MaxEndDate,
-                StartIndex = body.StartIndex,
-                Limit = body.Limit,
-                OrderBy = RequestHelpers.GetOrderBy(body.SortBy, body.SortOrder),
-                IsNews = body.IsNews,
-                IsMovie = body.IsMovie,
-                IsSeries = body.IsSeries,
-                IsKids = body.IsKids,
-                IsSports = body.IsSports,
-                SeriesTimerId = body.SeriesTimerId,
-                Genres = body.Genres,
-                GenreIds = body.GenreIds
-            };
-
-            if (!body.LibrarySeriesId.Equals(default))
+            if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series)
             {
-                query.IsSeries = true;
-
-                if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series)
-                {
-                    query.Name = series.Name;
-                }
+                query.Name = series.Name;
             }
-
-            var dtoOptions = new DtoOptions { Fields = body.Fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes);
-            return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
         }
 
-        /// <summary>
-        /// Gets recommended live tv epgs.
-        /// </summary>
-        /// <param name="userId">Optional. filter by user id.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param>
-        /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param>
-        /// <param name="isSeries">Optional. Filter for series.</param>
-        /// <param name="isMovie">Optional. Filter for movies.</param>
-        /// <param name="isNews">Optional. Filter for news.</param>
-        /// <param name="isKids">Optional. Filter for kids.</param>
-        /// <param name="isSports">Optional. Filter for sports.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="genreIds">The genres to return guide information for.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableUserData">Optional. include user data.</param>
-        /// <param name="enableTotalRecordCount">Retrieve total record count.</param>
-        /// <response code="200">Recommended epgs returned.</response>
-        /// <returns>A <see cref="OkResult"/> containing the queryresult of recommended epgs.</returns>
-        [HttpGet("Programs/Recommended")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<QueryResult<BaseItemDto>>> GetRecommendedPrograms(
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery] bool? isAiring,
-            [FromQuery] bool? hasAired,
-            [FromQuery] bool? isSeries,
-            [FromQuery] bool? isMovie,
-            [FromQuery] bool? isNews,
-            [FromQuery] bool? isKids,
-            [FromQuery] bool? isSports,
-            [FromQuery] bool? enableImages,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] bool enableTotalRecordCount = true)
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
+    }
+
+    /// <summary>
+    /// Gets available live tv epgs.
+    /// </summary>
+    /// <param name="body">Request body.</param>
+    /// <response code="200">Live tv epgs returned.</response>
+    /// <returns>
+    /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs.
+    /// </returns>
+    [HttpPost("Programs")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetPrograms([FromBody] GetProgramsDto body)
+    {
+        var user = body.UserId.Equals(default) ? null : _userManager.GetUserById(body.UserId);
+
+        var query = new InternalItemsQuery(user)
+        {
+            ChannelIds = body.ChannelIds,
+            HasAired = body.HasAired,
+            IsAiring = body.IsAiring,
+            EnableTotalRecordCount = body.EnableTotalRecordCount,
+            MinStartDate = body.MinStartDate,
+            MinEndDate = body.MinEndDate,
+            MaxStartDate = body.MaxStartDate,
+            MaxEndDate = body.MaxEndDate,
+            StartIndex = body.StartIndex,
+            Limit = body.Limit,
+            OrderBy = RequestHelpers.GetOrderBy(body.SortBy, body.SortOrder),
+            IsNews = body.IsNews,
+            IsMovie = body.IsMovie,
+            IsSeries = body.IsSeries,
+            IsKids = body.IsKids,
+            IsSports = body.IsSports,
+            SeriesTimerId = body.SeriesTimerId,
+            Genres = body.Genres,
+            GenreIds = body.GenreIds
+        };
+
+        if (!body.LibrarySeriesId.Equals(default))
         {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+            query.IsSeries = true;
 
-            var query = new InternalItemsQuery(user)
+            if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series)
             {
-                IsAiring = isAiring,
-                Limit = limit,
-                HasAired = hasAired,
-                IsSeries = isSeries,
-                IsMovie = isMovie,
-                IsKids = isKids,
-                IsNews = isNews,
-                IsSports = isSports,
-                EnableTotalRecordCount = enableTotalRecordCount,
-                GenreIds = genreIds
-            };
-
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            return await _liveTvManager.GetRecommendedProgramsAsync(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
+                query.Name = series.Name;
+            }
         }
 
-        /// <summary>
-        /// Gets a live tv program.
-        /// </summary>
-        /// <param name="programId">Program id.</param>
-        /// <param name="userId">Optional. Attach user data.</param>
-        /// <response code="200">Program returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the livetv program.</returns>
-        [HttpGet("Programs/{programId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<BaseItemDto>> GetProgram(
-            [FromRoute, Required] string programId,
-            [FromQuery] Guid? userId)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = body.Fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes);
+        return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
+    }
 
-            return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Gets recommended live tv epgs.
+    /// </summary>
+    /// <param name="userId">Optional. filter by user id.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param>
+    /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param>
+    /// <param name="isSeries">Optional. Filter for series.</param>
+    /// <param name="isMovie">Optional. Filter for movies.</param>
+    /// <param name="isNews">Optional. Filter for news.</param>
+    /// <param name="isKids">Optional. Filter for kids.</param>
+    /// <param name="isSports">Optional. Filter for sports.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="genreIds">The genres to return guide information for.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableUserData">Optional. include user data.</param>
+    /// <param name="enableTotalRecordCount">Retrieve total record count.</param>
+    /// <response code="200">Recommended epgs returned.</response>
+    /// <returns>A <see cref="OkResult"/> containing the queryresult of recommended epgs.</returns>
+    [HttpGet("Programs/Recommended")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetRecommendedPrograms(
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery] bool? isAiring,
+        [FromQuery] bool? hasAired,
+        [FromQuery] bool? isSeries,
+        [FromQuery] bool? isMovie,
+        [FromQuery] bool? isNews,
+        [FromQuery] bool? isKids,
+        [FromQuery] bool? isSports,
+        [FromQuery] bool? enableImages,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-        /// <summary>
-        /// Deletes a live tv recording.
-        /// </summary>
-        /// <param name="recordingId">Recording id.</param>
-        /// <response code="204">Recording deleted.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpDelete("Recordings/{recordingId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> DeleteRecording([FromRoute, Required] Guid recordingId)
+        var query = new InternalItemsQuery(user)
         {
-            await AssertUserCanManageLiveTv().ConfigureAwait(false);
+            IsAiring = isAiring,
+            Limit = limit,
+            HasAired = hasAired,
+            IsSeries = isSeries,
+            IsMovie = isMovie,
+            IsKids = isKids,
+            IsNews = isNews,
+            IsSports = isSports,
+            EnableTotalRecordCount = enableTotalRecordCount,
+            GenreIds = genreIds
+        };
+
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        return await _liveTvManager.GetRecommendedProgramsAsync(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
+    }
 
-            var item = _libraryManager.GetItemById(recordingId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+    /// <summary>
+    /// Gets a live tv program.
+    /// </summary>
+    /// <param name="programId">Program id.</param>
+    /// <param name="userId">Optional. Attach user data.</param>
+    /// <response code="200">Program returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the livetv program.</returns>
+    [HttpGet("Programs/{programId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<BaseItemDto>> GetProgram(
+        [FromRoute, Required] string programId,
+        [FromQuery] Guid? userId)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-            _libraryManager.DeleteItem(item, new DeleteOptions
-            {
-                DeleteFileLocation = false
-            });
+        return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false);
+    }
 
-            return NoContent();
-        }
+    /// <summary>
+    /// Deletes a live tv recording.
+    /// </summary>
+    /// <param name="recordingId">Recording id.</param>
+    /// <response code="204">Recording deleted.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpDelete("Recordings/{recordingId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> DeleteRecording([FromRoute, Required] Guid recordingId)
+    {
+        await AssertUserCanManageLiveTv().ConfigureAwait(false);
 
-        /// <summary>
-        /// Cancels a live tv timer.
-        /// </summary>
-        /// <param name="timerId">Timer id.</param>
-        /// <response code="204">Timer deleted.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Timers/{timerId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> CancelTimer([FromRoute, Required] string timerId)
+        var item = _libraryManager.GetItemById(recordingId);
+        if (item is null)
         {
-            await AssertUserCanManageLiveTv().ConfigureAwait(false);
-            await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false);
-            return NoContent();
+            return NotFound();
         }
 
-        /// <summary>
-        /// Updates a live tv timer.
-        /// </summary>
-        /// <param name="timerId">Timer id.</param>
-        /// <param name="timerInfo">New timer info.</param>
-        /// <response code="204">Timer updated.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Timers/{timerId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo)
+        _libraryManager.DeleteItem(item, new DeleteOptions
         {
-            await AssertUserCanManageLiveTv().ConfigureAwait(false);
-            await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
-            return NoContent();
-        }
+            DeleteFileLocation = false
+        });
 
-        /// <summary>
-        /// Creates a live tv timer.
-        /// </summary>
-        /// <param name="timerInfo">New timer info.</param>
-        /// <response code="204">Timer created.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Timers")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> CreateTimer([FromBody] TimerInfoDto timerInfo)
-        {
-            await AssertUserCanManageLiveTv().ConfigureAwait(false);
-            await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
-            return NoContent();
-        }
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Gets a live tv series timer.
-        /// </summary>
-        /// <param name="timerId">Timer id.</param>
-        /// <response code="200">Series timer returned.</response>
-        /// <response code="404">Series timer not found.</response>
-        /// <returns>A <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if timer not found.</returns>
-        [HttpGet("SeriesTimers/{timerId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult<SeriesTimerInfoDto>> GetSeriesTimer([FromRoute, Required] string timerId)
-        {
-            var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false);
-            if (timer is null)
-            {
-                return NotFound();
-            }
+    /// <summary>
+    /// Cancels a live tv timer.
+    /// </summary>
+    /// <param name="timerId">Timer id.</param>
+    /// <response code="204">Timer deleted.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Timers/{timerId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> CancelTimer([FromRoute, Required] string timerId)
+    {
+        await AssertUserCanManageLiveTv().ConfigureAwait(false);
+        await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            return timer;
-        }
+    /// <summary>
+    /// Updates a live tv timer.
+    /// </summary>
+    /// <param name="timerId">Timer id.</param>
+    /// <param name="timerInfo">New timer info.</param>
+    /// <response code="204">Timer updated.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Timers/{timerId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo)
+    {
+        await AssertUserCanManageLiveTv().ConfigureAwait(false);
+        await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Gets live tv series timers.
-        /// </summary>
-        /// <param name="sortBy">Optional. Sort by SortName or Priority.</param>
-        /// <param name="sortOrder">Optional. Sort in Ascending or Descending order.</param>
-        /// <response code="200">Timers returned.</response>
-        /// <returns>An <see cref="OkResult"/> of live tv series timers.</returns>
-        [HttpGet("SeriesTimers")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<QueryResult<SeriesTimerInfoDto>>> GetSeriesTimers([FromQuery] string? sortBy, [FromQuery] SortOrder? sortOrder)
-        {
-            return await _liveTvManager.GetSeriesTimers(
-                new SeriesTimerQuery
-                {
-                    SortOrder = sortOrder ?? SortOrder.Ascending,
-                    SortBy = sortBy
-                },
-                CancellationToken.None).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Creates a live tv timer.
+    /// </summary>
+    /// <param name="timerInfo">New timer info.</param>
+    /// <response code="204">Timer created.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Timers")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> CreateTimer([FromBody] TimerInfoDto timerInfo)
+    {
+        await AssertUserCanManageLiveTv().ConfigureAwait(false);
+        await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Cancels a live tv series timer.
-        /// </summary>
-        /// <param name="timerId">Timer id.</param>
-        /// <response code="204">Timer cancelled.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("SeriesTimers/{timerId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> CancelSeriesTimer([FromRoute, Required] string timerId)
+    /// <summary>
+    /// Gets a live tv series timer.
+    /// </summary>
+    /// <param name="timerId">Timer id.</param>
+    /// <response code="200">Series timer returned.</response>
+    /// <response code="404">Series timer not found.</response>
+    /// <returns>A <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if timer not found.</returns>
+    [HttpGet("SeriesTimers/{timerId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<SeriesTimerInfoDto>> GetSeriesTimer([FromRoute, Required] string timerId)
+    {
+        var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false);
+        if (timer is null)
         {
-            await AssertUserCanManageLiveTv().ConfigureAwait(false);
-            await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false);
-            return NoContent();
+            return NotFound();
         }
 
-        /// <summary>
-        /// Updates a live tv series timer.
-        /// </summary>
-        /// <param name="timerId">Timer id.</param>
-        /// <param name="seriesTimerInfo">New series timer info.</param>
-        /// <response code="204">Series timer updated.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("SeriesTimers/{timerId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo)
-        {
-            await AssertUserCanManageLiveTv().ConfigureAwait(false);
-            await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
-            return NoContent();
-        }
+        return timer;
+    }
 
-        /// <summary>
-        /// Creates a live tv series timer.
-        /// </summary>
-        /// <param name="seriesTimerInfo">New series timer info.</param>
-        /// <response code="204">Series timer info created.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("SeriesTimers")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> CreateSeriesTimer([FromBody] SeriesTimerInfoDto seriesTimerInfo)
-        {
-            await AssertUserCanManageLiveTv().ConfigureAwait(false);
-            await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
-            return NoContent();
-        }
+    /// <summary>
+    /// Gets live tv series timers.
+    /// </summary>
+    /// <param name="sortBy">Optional. Sort by SortName or Priority.</param>
+    /// <param name="sortOrder">Optional. Sort in Ascending or Descending order.</param>
+    /// <response code="200">Timers returned.</response>
+    /// <returns>An <see cref="OkResult"/> of live tv series timers.</returns>
+    [HttpGet("SeriesTimers")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<QueryResult<SeriesTimerInfoDto>>> GetSeriesTimers([FromQuery] string? sortBy, [FromQuery] SortOrder? sortOrder)
+    {
+        return await _liveTvManager.GetSeriesTimers(
+            new SeriesTimerQuery
+            {
+                SortOrder = sortOrder ?? SortOrder.Ascending,
+                SortBy = sortBy
+            },
+            CancellationToken.None).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Get recording group.
-        /// </summary>
-        /// <param name="groupId">Group id.</param>
-        /// <returns>A <see cref="NotFoundResult"/>.</returns>
-        [HttpGet("Recordings/Groups/{groupId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [Obsolete("This endpoint is obsolete.")]
-        public ActionResult<BaseItemDto> GetRecordingGroup([FromRoute, Required] Guid groupId)
-        {
-            return NotFound();
-        }
+    /// <summary>
+    /// Cancels a live tv series timer.
+    /// </summary>
+    /// <param name="timerId">Timer id.</param>
+    /// <response code="204">Timer cancelled.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("SeriesTimers/{timerId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> CancelSeriesTimer([FromRoute, Required] string timerId)
+    {
+        await AssertUserCanManageLiveTv().ConfigureAwait(false);
+        await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Get guid info.
-        /// </summary>
-        /// <response code="200">Guid info returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the guide info.</returns>
-        [HttpGet("GuideInfo")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<GuideInfo> GetGuideInfo()
-        {
-            return _liveTvManager.GetGuideInfo();
-        }
+    /// <summary>
+    /// Updates a live tv series timer.
+    /// </summary>
+    /// <param name="timerId">Timer id.</param>
+    /// <param name="seriesTimerInfo">New series timer info.</param>
+    /// <response code="204">Series timer updated.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("SeriesTimers/{timerId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo)
+    {
+        await AssertUserCanManageLiveTv().ConfigureAwait(false);
+        await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Adds a tuner host.
-        /// </summary>
-        /// <param name="tunerHostInfo">New tuner host.</param>
-        /// <response code="200">Created tuner host returned.</response>
-        /// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
-        [HttpPost("TunerHosts")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
-        {
-            return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Creates a live tv series timer.
+    /// </summary>
+    /// <param name="seriesTimerInfo">New series timer info.</param>
+    /// <response code="204">Series timer info created.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("SeriesTimers")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> CreateSeriesTimer([FromBody] SeriesTimerInfoDto seriesTimerInfo)
+    {
+        await AssertUserCanManageLiveTv().ConfigureAwait(false);
+        await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Deletes a tuner host.
-        /// </summary>
-        /// <param name="id">Tuner host id.</param>
-        /// <response code="204">Tuner host deleted.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("TunerHosts")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult DeleteTunerHost([FromQuery] string? id)
-        {
-            var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv");
-            config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
-            _configurationManager.SaveConfiguration("livetv", config);
-            return NoContent();
-        }
+    /// <summary>
+    /// Get recording group.
+    /// </summary>
+    /// <param name="groupId">Group id.</param>
+    /// <returns>A <see cref="NotFoundResult"/>.</returns>
+    [HttpGet("Recordings/Groups/{groupId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Obsolete("This endpoint is obsolete.")]
+    public ActionResult<BaseItemDto> GetRecordingGroup([FromRoute, Required] Guid groupId)
+    {
+        return NotFound();
+    }
 
-        /// <summary>
-        /// Gets default listings provider info.
-        /// </summary>
-        /// <response code="200">Default listings provider info returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the default listings provider info.</returns>
-        [HttpGet("ListingProviders/Default")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<ListingsProviderInfo> GetDefaultListingProvider()
-        {
-            return new ListingsProviderInfo();
-        }
+    /// <summary>
+    /// Get guid info.
+    /// </summary>
+    /// <response code="200">Guid info returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the guide info.</returns>
+    [HttpGet("GuideInfo")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<GuideInfo> GetGuideInfo()
+    {
+        return _liveTvManager.GetGuideInfo();
+    }
 
-        /// <summary>
-        /// Adds a listings provider.
-        /// </summary>
-        /// <param name="pw">Password.</param>
-        /// <param name="listingsProviderInfo">New listings info.</param>
-        /// <param name="validateListings">Validate listings.</param>
-        /// <param name="validateLogin">Validate login.</param>
-        /// <response code="200">Created listings provider returned.</response>
-        /// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
-        [HttpPost("ListingProviders")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
-            [FromQuery] string? pw,
-            [FromBody] ListingsProviderInfo listingsProviderInfo,
-            [FromQuery] bool validateListings = false,
-            [FromQuery] bool validateLogin = false)
-        {
-            if (!string.IsNullOrEmpty(pw))
-            {
-                // TODO: remove ToLower when Convert.ToHexString supports lowercase
-                // Schedules Direct requires the hex to be lowercase
-                listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
-            }
+    /// <summary>
+    /// Adds a tuner host.
+    /// </summary>
+    /// <param name="tunerHostInfo">New tuner host.</param>
+    /// <response code="200">Created tuner host returned.</response>
+    /// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
+    [HttpPost("TunerHosts")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
+    {
+        return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
+    }
 
-            return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Deletes a tuner host.
+    /// </summary>
+    /// <param name="id">Tuner host id.</param>
+    /// <response code="204">Tuner host deleted.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("TunerHosts")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult DeleteTunerHost([FromQuery] string? id)
+    {
+        var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv");
+        config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
+        _configurationManager.SaveConfiguration("livetv", config);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Delete listing provider.
-        /// </summary>
-        /// <param name="id">Listing provider id.</param>
-        /// <response code="204">Listing provider deleted.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("ListingProviders")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult DeleteListingProvider([FromQuery] string? id)
-        {
-            _liveTvManager.DeleteListingsProvider(id);
-            return NoContent();
-        }
+    /// <summary>
+    /// Gets default listings provider info.
+    /// </summary>
+    /// <response code="200">Default listings provider info returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the default listings provider info.</returns>
+    [HttpGet("ListingProviders/Default")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<ListingsProviderInfo> GetDefaultListingProvider()
+    {
+        return new ListingsProviderInfo();
+    }
 
-        /// <summary>
-        /// Gets available lineups.
-        /// </summary>
-        /// <param name="id">Provider id.</param>
-        /// <param name="type">Provider type.</param>
-        /// <param name="location">Location.</param>
-        /// <param name="country">Country.</param>
-        /// <response code="200">Available lineups returned.</response>
-        /// <returns>A <see cref="OkResult"/> containing the available lineups.</returns>
-        [HttpGet("ListingProviders/Lineups")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<IEnumerable<NameIdPair>>> GetLineups(
-            [FromQuery] string? id,
-            [FromQuery] string? type,
-            [FromQuery] string? location,
-            [FromQuery] string? country)
+    /// <summary>
+    /// Adds a listings provider.
+    /// </summary>
+    /// <param name="pw">Password.</param>
+    /// <param name="listingsProviderInfo">New listings info.</param>
+    /// <param name="validateListings">Validate listings.</param>
+    /// <param name="validateLogin">Validate login.</param>
+    /// <response code="200">Created listings provider returned.</response>
+    /// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
+    [HttpPost("ListingProviders")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
+        [FromQuery] string? pw,
+        [FromBody] ListingsProviderInfo listingsProviderInfo,
+        [FromQuery] bool validateListings = false,
+        [FromQuery] bool validateLogin = false)
+    {
+        if (!string.IsNullOrEmpty(pw))
         {
-            return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false);
+            // TODO: remove ToLower when Convert.ToHexString supports lowercase
+            // Schedules Direct requires the hex to be lowercase
+            listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
         }
 
-        /// <summary>
-        /// Gets available countries.
-        /// </summary>
-        /// <response code="200">Available countries returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
-        [HttpGet("ListingProviders/SchedulesDirect/Countries")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesFile(MediaTypeNames.Application.Json)]
-        public async Task<ActionResult> GetSchedulesDirectCountries()
-        {
-            var client = _httpClientFactory.CreateClient(NamedClient.Default);
-            // https://json.schedulesdirect.org/20141201/available/countries
-            // Can't dispose the response as it's required up the call chain.
-            var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries"))
-                .ConfigureAwait(false);
+        return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
+    }
 
-            return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json);
-        }
+    /// <summary>
+    /// Delete listing provider.
+    /// </summary>
+    /// <param name="id">Listing provider id.</param>
+    /// <response code="204">Listing provider deleted.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("ListingProviders")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult DeleteListingProvider([FromQuery] string? id)
+    {
+        _liveTvManager.DeleteListingsProvider(id);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Get channel mapping options.
-        /// </summary>
-        /// <param name="providerId">Provider id.</param>
-        /// <response code="200">Channel mapping options returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
-        [HttpGet("ChannelMappingOptions")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<ChannelMappingOptionsDto>> GetChannelMappingOptions([FromQuery] string? providerId)
-        {
-            var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv");
+    /// <summary>
+    /// Gets available lineups.
+    /// </summary>
+    /// <param name="id">Provider id.</param>
+    /// <param name="type">Provider type.</param>
+    /// <param name="location">Location.</param>
+    /// <param name="country">Country.</param>
+    /// <response code="200">Available lineups returned.</response>
+    /// <returns>A <see cref="OkResult"/> containing the available lineups.</returns>
+    [HttpGet("ListingProviders/Lineups")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<IEnumerable<NameIdPair>>> GetLineups(
+        [FromQuery] string? id,
+        [FromQuery] string? type,
+        [FromQuery] string? location,
+        [FromQuery] string? country)
+    {
+        return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false);
+    }
 
-            var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase));
+    /// <summary>
+    /// Gets available countries.
+    /// </summary>
+    /// <response code="200">Available countries returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
+    [HttpGet("ListingProviders/SchedulesDirect/Countries")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesFile(MediaTypeNames.Application.Json)]
+    public async Task<ActionResult> GetSchedulesDirectCountries()
+    {
+        var client = _httpClientFactory.CreateClient(NamedClient.Default);
+        // https://json.schedulesdirect.org/20141201/available/countries
+        // Can't dispose the response as it's required up the call chain.
+        var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries"))
+            .ConfigureAwait(false);
 
-            var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name;
+        return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json);
+    }
 
-            var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get channel mapping options.
+    /// </summary>
+    /// <param name="providerId">Provider id.</param>
+    /// <response code="200">Channel mapping options returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
+    [HttpGet("ChannelMappingOptions")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<ChannelMappingOptionsDto>> GetChannelMappingOptions([FromQuery] string? providerId)
+    {
+        var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv");
 
-            var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None)
-                .ConfigureAwait(false);
+        var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase));
 
-            var mappings = listingsProviderInfo.ChannelMappings;
+        var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name;
 
-            return new ChannelMappingOptionsDto
-            {
-                TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(),
-                ProviderChannels = providerChannels.Select(i => new NameIdPair
-                {
-                    Name = i.Name,
-                    Id = i.Id
-                }).ToList(),
-                Mappings = mappings,
-                ProviderName = listingsProviderName
-            };
-        }
+        var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None)
+            .ConfigureAwait(false);
 
-        /// <summary>
-        /// Set channel mappings.
-        /// </summary>
-        /// <param name="setChannelMappingDto">The set channel mapping dto.</param>
-        /// <response code="200">Created channel mapping returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
-        [HttpPost("ChannelMappings")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto)
-        {
-            return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false);
-        }
+        var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None)
+            .ConfigureAwait(false);
 
-        /// <summary>
-        /// Get tuner host types.
-        /// </summary>
-        /// <response code="200">Tuner host types returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the tuner host types.</returns>
-        [HttpGet("TunerHosts/Types")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<NameIdPair>> GetTunerHostTypes()
-        {
-            return _liveTvManager.GetTunerHostTypes();
-        }
+        var mappings = listingsProviderInfo.ChannelMappings;
 
-        /// <summary>
-        /// Discover tuners.
-        /// </summary>
-        /// <param name="newDevicesOnly">Only discover new tuners.</param>
-        /// <response code="200">Tuners returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
-        [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
-        [HttpGet("Tuners/Discover")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
+        return new ChannelMappingOptionsDto
         {
-            return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false);
-        }
+            TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(),
+            ProviderChannels = providerChannels.Select(i => new NameIdPair
+            {
+                Name = i.Name,
+                Id = i.Id
+            }).ToList(),
+            Mappings = mappings,
+            ProviderName = listingsProviderName
+        };
+    }
 
-        /// <summary>
-        /// Gets a live tv recording stream.
-        /// </summary>
-        /// <param name="recordingId">Recording id.</param>
-        /// <response code="200">Recording stream returned.</response>
-        /// <response code="404">Recording not found.</response>
-        /// <returns>
-        /// An <see cref="OkResult"/> containing the recording stream on success,
-        /// or a <see cref="NotFoundResult"/> if recording not found.
-        /// </returns>
-        [HttpGet("LiveRecordings/{recordingId}/stream")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesVideoFile]
-        public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId)
-        {
-            var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId);
+    /// <summary>
+    /// Set channel mappings.
+    /// </summary>
+    /// <param name="setChannelMappingDto">The set channel mapping dto.</param>
+    /// <response code="200">Created channel mapping returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
+    [HttpPost("ChannelMappings")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto)
+    {
+        return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false);
+    }
 
-            if (string.IsNullOrWhiteSpace(path))
-            {
-                return NotFound();
-            }
+    /// <summary>
+    /// Get tuner host types.
+    /// </summary>
+    /// <response code="200">Tuner host types returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the tuner host types.</returns>
+    [HttpGet("TunerHosts/Types")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<NameIdPair>> GetTunerHostTypes()
+    {
+        return _liveTvManager.GetTunerHostTypes();
+    }
 
-            var stream = new ProgressiveFileStream(path, null, _transcodingJobHelper);
-            return new FileStreamResult(stream, MimeTypes.GetMimeType(path));
-        }
+    /// <summary>
+    /// Discover tuners.
+    /// </summary>
+    /// <param name="newDevicesOnly">Only discover new tuners.</param>
+    /// <response code="200">Tuners returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
+    [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
+    [HttpGet("Tuners/Discover")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
+    {
+        return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Gets a live tv channel stream.
-        /// </summary>
-        /// <param name="streamId">Stream id.</param>
-        /// <param name="container">Container type.</param>
-        /// <response code="200">Stream returned.</response>
-        /// <response code="404">Stream not found.</response>
-        /// <returns>
-        /// An <see cref="OkResult"/> containing the channel stream on success,
-        /// or a <see cref="NotFoundResult"/> if stream not found.
-        /// </returns>
-        [HttpGet("LiveStreamFiles/{streamId}/stream.{container}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesVideoFile]
-        public ActionResult GetLiveStreamFile([FromRoute, Required] string streamId, [FromRoute, Required] string container)
+    /// <summary>
+    /// Gets a live tv recording stream.
+    /// </summary>
+    /// <param name="recordingId">Recording id.</param>
+    /// <response code="200">Recording stream returned.</response>
+    /// <response code="404">Recording not found.</response>
+    /// <returns>
+    /// An <see cref="OkResult"/> containing the recording stream on success,
+    /// or a <see cref="NotFoundResult"/> if recording not found.
+    /// </returns>
+    [HttpGet("LiveRecordings/{recordingId}/stream")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesVideoFile]
+    public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId)
+    {
+        var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId);
+
+        if (string.IsNullOrWhiteSpace(path))
         {
-            var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
-            if (liveStreamInfo is null)
-            {
-                return NotFound();
-            }
+            return NotFound();
+        }
+
+        var stream = new ProgressiveFileStream(path, null, _transcodingJobHelper);
+        return new FileStreamResult(stream, MimeTypes.GetMimeType(path));
+    }
 
-            var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream());
-            return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container));
+    /// <summary>
+    /// Gets a live tv channel stream.
+    /// </summary>
+    /// <param name="streamId">Stream id.</param>
+    /// <param name="container">Container type.</param>
+    /// <response code="200">Stream returned.</response>
+    /// <response code="404">Stream not found.</response>
+    /// <returns>
+    /// An <see cref="OkResult"/> containing the channel stream on success,
+    /// or a <see cref="NotFoundResult"/> if stream not found.
+    /// </returns>
+    [HttpGet("LiveStreamFiles/{streamId}/stream.{container}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesVideoFile]
+    public ActionResult GetLiveStreamFile([FromRoute, Required] string streamId, [FromRoute, Required] string container)
+    {
+        var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
+        if (liveStreamInfo is null)
+        {
+            return NotFound();
         }
 
-        private async Task AssertUserCanManageLiveTv()
+        var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream());
+        return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container));
+    }
+
+    private async Task AssertUserCanManageLiveTv()
+    {
+        var user = _userManager.GetUserById(User.GetUserId());
+        var session = await _sessionManager.LogSessionActivity(
+            User.GetClient(),
+            User.GetVersion(),
+            User.GetDeviceId(),
+            User.GetDevice(),
+            HttpContext.GetNormalizedRemoteIp().ToString(),
+            user).ConfigureAwait(false);
+
+        if (session.UserId.Equals(default))
         {
-            var user = _userManager.GetUserById(User.GetUserId());
-            var session = await _sessionManager.LogSessionActivity(
-                User.GetClient(),
-                User.GetVersion(),
-                User.GetDeviceId(),
-                User.GetDevice(),
-                HttpContext.GetNormalizedRemoteIp().ToString(),
-                user).ConfigureAwait(false);
-
-            if (session.UserId.Equals(default))
-            {
-                throw new SecurityException("Anonymous live tv management is not allowed.");
-            }
+            throw new SecurityException("Anonymous live tv management is not allowed.");
+        }
 
-            if (!user.HasPermission(PermissionKind.EnableLiveTvManagement))
-            {
-                throw new SecurityException("The current user does not have permission to manage live tv.");
-            }
+        if (!user.HasPermission(PermissionKind.EnableLiveTvManagement))
+        {
+            throw new SecurityException("The current user does not have permission to manage live tv.");
         }
     }
 }

+ 57 - 58
Jellyfin.Api/Controllers/LocalizationController.cs

@@ -6,71 +6,70 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Localization controller.
+/// </summary>
+[Authorize(Policy = Policies.FirstTimeSetupOrDefault)]
+public class LocalizationController : BaseJellyfinApiController
 {
+    private readonly ILocalizationManager _localization;
+
     /// <summary>
-    /// Localization controller.
+    /// Initializes a new instance of the <see cref="LocalizationController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.FirstTimeSetupOrDefault)]
-    public class LocalizationController : BaseJellyfinApiController
+    /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+    public LocalizationController(ILocalizationManager localization)
     {
-        private readonly ILocalizationManager _localization;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="LocalizationController"/> class.
-        /// </summary>
-        /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
-        public LocalizationController(ILocalizationManager localization)
-        {
-            _localization = localization;
-        }
+        _localization = localization;
+    }
 
-        /// <summary>
-        /// Gets known cultures.
-        /// </summary>
-        /// <response code="200">Known cultures returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the list of cultures.</returns>
-        [HttpGet("Cultures")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<CultureDto>> GetCultures()
-        {
-            return Ok(_localization.GetCultures());
-        }
+    /// <summary>
+    /// Gets known cultures.
+    /// </summary>
+    /// <response code="200">Known cultures returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the list of cultures.</returns>
+    [HttpGet("Cultures")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<CultureDto>> GetCultures()
+    {
+        return Ok(_localization.GetCultures());
+    }
 
-        /// <summary>
-        /// Gets known countries.
-        /// </summary>
-        /// <response code="200">Known countries returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the list of countries.</returns>
-        [HttpGet("Countries")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<CountryInfo>> GetCountries()
-        {
-            return Ok(_localization.GetCountries());
-        }
+    /// <summary>
+    /// Gets known countries.
+    /// </summary>
+    /// <response code="200">Known countries returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the list of countries.</returns>
+    [HttpGet("Countries")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<CountryInfo>> GetCountries()
+    {
+        return Ok(_localization.GetCountries());
+    }
 
-        /// <summary>
-        /// Gets known parental ratings.
-        /// </summary>
-        /// <response code="200">Known parental ratings returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the list of parental ratings.</returns>
-        [HttpGet("ParentalRatings")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<ParentalRating>> GetParentalRatings()
-        {
-            return Ok(_localization.GetParentalRatings());
-        }
+    /// <summary>
+    /// Gets known parental ratings.
+    /// </summary>
+    /// <response code="200">Known parental ratings returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the list of parental ratings.</returns>
+    [HttpGet("ParentalRatings")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<ParentalRating>> GetParentalRatings()
+    {
+        return Ok(_localization.GetParentalRatings());
+    }
 
-        /// <summary>
-        /// Gets localization options.
-        /// </summary>
-        /// <response code="200">Localization options returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the list of localization options.</returns>
-        [HttpGet("Options")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<LocalizationOption>> GetLocalizationOptions()
-        {
-            return Ok(_localization.GetLocalizationOptions());
-        }
+    /// <summary>
+    /// Gets localization options.
+    /// </summary>
+    /// <response code="200">Localization options returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the list of localization options.</returns>
+    [HttpGet("Options")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<LocalizationOption>> GetLocalizationOptions()
+    {
+        return Ok(_localization.GetLocalizationOptions());
     }
 }

+ 260 - 261
Jellyfin.Api/Controllers/MediaInfoController.cs

@@ -19,295 +19,294 @@ using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.ModelBinding;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The media info controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class MediaInfoController : BaseJellyfinApiController
 {
+    private readonly IMediaSourceManager _mediaSourceManager;
+    private readonly IDeviceManager _deviceManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILogger<MediaInfoController> _logger;
+    private readonly MediaInfoHelper _mediaInfoHelper;
+
     /// <summary>
-    /// The media info controller.
+    /// Initializes a new instance of the <see cref="MediaInfoController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class MediaInfoController : BaseJellyfinApiController
+    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param>
+    /// <param name="mediaInfoHelper">Instance of the <see cref="MediaInfoHelper"/>.</param>
+    public MediaInfoController(
+        IMediaSourceManager mediaSourceManager,
+        IDeviceManager deviceManager,
+        ILibraryManager libraryManager,
+        ILogger<MediaInfoController> logger,
+        MediaInfoHelper mediaInfoHelper)
     {
-        private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IDeviceManager _deviceManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly ILogger<MediaInfoController> _logger;
-        private readonly MediaInfoHelper _mediaInfoHelper;
+        _mediaSourceManager = mediaSourceManager;
+        _deviceManager = deviceManager;
+        _libraryManager = libraryManager;
+        _logger = logger;
+        _mediaInfoHelper = mediaInfoHelper;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="MediaInfoController"/> class.
-        /// </summary>
-        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param>
-        /// <param name="mediaInfoHelper">Instance of the <see cref="MediaInfoHelper"/>.</param>
-        public MediaInfoController(
-            IMediaSourceManager mediaSourceManager,
-            IDeviceManager deviceManager,
-            ILibraryManager libraryManager,
-            ILogger<MediaInfoController> logger,
-            MediaInfoHelper mediaInfoHelper)
-        {
-            _mediaSourceManager = mediaSourceManager;
-            _deviceManager = deviceManager;
-            _libraryManager = libraryManager;
-            _logger = logger;
-            _mediaInfoHelper = mediaInfoHelper;
-        }
+    /// <summary>
+    /// Gets live playback media info for an item.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="userId">The user id.</param>
+    /// <response code="200">Playback info returned.</response>
+    /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns>
+    [HttpGet("Items/{itemId}/PlaybackInfo")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId)
+    {
+        return await _mediaInfoHelper.GetPlaybackInfo(
+                itemId,
+                userId)
+            .ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Gets live playback media info for an item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="userId">The user id.</param>
-        /// <response code="200">Playback info returned.</response>
-        /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns>
-        [HttpGet("Items/{itemId}/PlaybackInfo")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId)
-        {
-            return await _mediaInfoHelper.GetPlaybackInfo(
-                    itemId,
-                    userId)
-                .ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Gets live playback media info for an item.
+    /// </summary>
+    /// <remarks>
+    /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.
+    /// Query parameters are obsolete.
+    /// </remarks>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="userId">The user id.</param>
+    /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
+    /// <param name="startTimeTicks">The start time in ticks.</param>
+    /// <param name="audioStreamIndex">The audio stream index.</param>
+    /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+    /// <param name="maxAudioChannels">The maximum number of audio channels.</param>
+    /// <param name="mediaSourceId">The media source id.</param>
+    /// <param name="liveStreamId">The livestream id.</param>
+    /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param>
+    /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
+    /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
+    /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param>
+    /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param>
+    /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param>
+    /// <param name="playbackInfoDto">The playback info.</param>
+    /// <response code="200">Playback info returned.</response>
+    /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns>
+    [HttpPost("Items/{itemId}/PlaybackInfo")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery, ParameterObsolete] Guid? userId,
+        [FromQuery, ParameterObsolete] int? maxStreamingBitrate,
+        [FromQuery, ParameterObsolete] long? startTimeTicks,
+        [FromQuery, ParameterObsolete] int? audioStreamIndex,
+        [FromQuery, ParameterObsolete] int? subtitleStreamIndex,
+        [FromQuery, ParameterObsolete] int? maxAudioChannels,
+        [FromQuery, ParameterObsolete] string? mediaSourceId,
+        [FromQuery, ParameterObsolete] string? liveStreamId,
+        [FromQuery, ParameterObsolete] bool? autoOpenLiveStream,
+        [FromQuery, ParameterObsolete] bool? enableDirectPlay,
+        [FromQuery, ParameterObsolete] bool? enableDirectStream,
+        [FromQuery, ParameterObsolete] bool? enableTranscoding,
+        [FromQuery, ParameterObsolete] bool? allowVideoStreamCopy,
+        [FromQuery, ParameterObsolete] bool? allowAudioStreamCopy,
+        [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] PlaybackInfoDto? playbackInfoDto)
+    {
+        var profile = playbackInfoDto?.DeviceProfile;
+        _logger.LogDebug("GetPostedPlaybackInfo profile: {@Profile}", profile);
 
-        /// <summary>
-        /// Gets live playback media info for an item.
-        /// </summary>
-        /// <remarks>
-        /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.
-        /// Query parameters are obsolete.
-        /// </remarks>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
-        /// <param name="startTimeTicks">The start time in ticks.</param>
-        /// <param name="audioStreamIndex">The audio stream index.</param>
-        /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
-        /// <param name="maxAudioChannels">The maximum number of audio channels.</param>
-        /// <param name="mediaSourceId">The media source id.</param>
-        /// <param name="liveStreamId">The livestream id.</param>
-        /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param>
-        /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
-        /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
-        /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param>
-        /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param>
-        /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param>
-        /// <param name="playbackInfoDto">The playback info.</param>
-        /// <response code="200">Playback info returned.</response>
-        /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns>
-        [HttpPost("Items/{itemId}/PlaybackInfo")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery, ParameterObsolete] Guid? userId,
-            [FromQuery, ParameterObsolete] int? maxStreamingBitrate,
-            [FromQuery, ParameterObsolete] long? startTimeTicks,
-            [FromQuery, ParameterObsolete] int? audioStreamIndex,
-            [FromQuery, ParameterObsolete] int? subtitleStreamIndex,
-            [FromQuery, ParameterObsolete] int? maxAudioChannels,
-            [FromQuery, ParameterObsolete] string? mediaSourceId,
-            [FromQuery, ParameterObsolete] string? liveStreamId,
-            [FromQuery, ParameterObsolete] bool? autoOpenLiveStream,
-            [FromQuery, ParameterObsolete] bool? enableDirectPlay,
-            [FromQuery, ParameterObsolete] bool? enableDirectStream,
-            [FromQuery, ParameterObsolete] bool? enableTranscoding,
-            [FromQuery, ParameterObsolete] bool? allowVideoStreamCopy,
-            [FromQuery, ParameterObsolete] bool? allowAudioStreamCopy,
-            [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] PlaybackInfoDto? playbackInfoDto)
+        if (profile is null)
         {
-            var profile = playbackInfoDto?.DeviceProfile;
-            _logger.LogDebug("GetPostedPlaybackInfo profile: {@Profile}", profile);
-
-            if (profile is null)
+            var caps = _deviceManager.GetCapabilities(User.GetDeviceId());
+            if (caps is not null)
             {
-                var caps = _deviceManager.GetCapabilities(User.GetDeviceId());
-                if (caps is not null)
-                {
-                    profile = caps.DeviceProfile;
-                }
+                profile = caps.DeviceProfile;
             }
+        }
 
-            // Copy params from posted body
-            // TODO clean up when breaking API compatibility.
-            userId ??= playbackInfoDto?.UserId;
-            maxStreamingBitrate ??= playbackInfoDto?.MaxStreamingBitrate;
-            startTimeTicks ??= playbackInfoDto?.StartTimeTicks;
-            audioStreamIndex ??= playbackInfoDto?.AudioStreamIndex;
-            subtitleStreamIndex ??= playbackInfoDto?.SubtitleStreamIndex;
-            maxAudioChannels ??= playbackInfoDto?.MaxAudioChannels;
-            mediaSourceId ??= playbackInfoDto?.MediaSourceId;
-            liveStreamId ??= playbackInfoDto?.LiveStreamId;
-            autoOpenLiveStream ??= playbackInfoDto?.AutoOpenLiveStream ?? false;
-            enableDirectPlay ??= playbackInfoDto?.EnableDirectPlay ?? true;
-            enableDirectStream ??= playbackInfoDto?.EnableDirectStream ?? true;
-            enableTranscoding ??= playbackInfoDto?.EnableTranscoding ?? true;
-            allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true;
-            allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true;
+        // Copy params from posted body
+        // TODO clean up when breaking API compatibility.
+        userId ??= playbackInfoDto?.UserId;
+        maxStreamingBitrate ??= playbackInfoDto?.MaxStreamingBitrate;
+        startTimeTicks ??= playbackInfoDto?.StartTimeTicks;
+        audioStreamIndex ??= playbackInfoDto?.AudioStreamIndex;
+        subtitleStreamIndex ??= playbackInfoDto?.SubtitleStreamIndex;
+        maxAudioChannels ??= playbackInfoDto?.MaxAudioChannels;
+        mediaSourceId ??= playbackInfoDto?.MediaSourceId;
+        liveStreamId ??= playbackInfoDto?.LiveStreamId;
+        autoOpenLiveStream ??= playbackInfoDto?.AutoOpenLiveStream ?? false;
+        enableDirectPlay ??= playbackInfoDto?.EnableDirectPlay ?? true;
+        enableDirectStream ??= playbackInfoDto?.EnableDirectStream ?? true;
+        enableTranscoding ??= playbackInfoDto?.EnableTranscoding ?? true;
+        allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true;
+        allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true;
 
-            var info = await _mediaInfoHelper.GetPlaybackInfo(
-                    itemId,
-                    userId,
-                    mediaSourceId,
-                    liveStreamId)
-                .ConfigureAwait(false);
+        var info = await _mediaInfoHelper.GetPlaybackInfo(
+                itemId,
+                userId,
+                mediaSourceId,
+                liveStreamId)
+            .ConfigureAwait(false);
 
-            if (info.ErrorCode is not null)
-            {
-                return info;
-            }
+        if (info.ErrorCode is not null)
+        {
+            return info;
+        }
+
+        if (profile is not null)
+        {
+            // set device specific data
+            var item = _libraryManager.GetItemById(itemId);
 
-            if (profile is not null)
+            foreach (var mediaSource in info.MediaSources)
             {
-                // set device specific data
-                var item = _libraryManager.GetItemById(itemId);
+                _mediaInfoHelper.SetDeviceSpecificData(
+                    item,
+                    mediaSource,
+                    profile,
+                    User,
+                    maxStreamingBitrate ?? profile.MaxStreamingBitrate,
+                    startTimeTicks ?? 0,
+                    mediaSourceId ?? string.Empty,
+                    audioStreamIndex,
+                    subtitleStreamIndex,
+                    maxAudioChannels,
+                    info.PlaySessionId!,
+                    userId ?? Guid.Empty,
+                    enableDirectPlay.Value,
+                    enableDirectStream.Value,
+                    enableTranscoding.Value,
+                    allowVideoStreamCopy.Value,
+                    allowAudioStreamCopy.Value,
+                    Request.HttpContext.GetNormalizedRemoteIp());
+            }
 
-                foreach (var mediaSource in info.MediaSources)
-                {
-                    _mediaInfoHelper.SetDeviceSpecificData(
-                        item,
-                        mediaSource,
-                        profile,
-                        User,
-                        maxStreamingBitrate ?? profile.MaxStreamingBitrate,
-                        startTimeTicks ?? 0,
-                        mediaSourceId ?? string.Empty,
-                        audioStreamIndex,
-                        subtitleStreamIndex,
-                        maxAudioChannels,
-                        info.PlaySessionId!,
-                        userId ?? Guid.Empty,
-                        enableDirectPlay.Value,
-                        enableDirectStream.Value,
-                        enableTranscoding.Value,
-                        allowVideoStreamCopy.Value,
-                        allowAudioStreamCopy.Value,
-                        Request.HttpContext.GetNormalizedRemoteIp());
-                }
+            _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
+        }
 
-                _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
-            }
+        if (autoOpenLiveStream.Value)
+        {
+            var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal));
 
-            if (autoOpenLiveStream.Value)
+            if (mediaSource is not null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId))
             {
-                var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal));
-
-                if (mediaSource is not null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId))
-                {
-                    var openStreamResult = await _mediaInfoHelper.OpenMediaSource(
-                        HttpContext,
-                        new LiveStreamRequest
-                        {
-                            AudioStreamIndex = audioStreamIndex,
-                            DeviceProfile = playbackInfoDto?.DeviceProfile,
-                            EnableDirectPlay = enableDirectPlay.Value,
-                            EnableDirectStream = enableDirectStream.Value,
-                            ItemId = itemId,
-                            MaxAudioChannels = maxAudioChannels,
-                            MaxStreamingBitrate = maxStreamingBitrate,
-                            PlaySessionId = info.PlaySessionId,
-                            StartTimeTicks = startTimeTicks,
-                            SubtitleStreamIndex = subtitleStreamIndex,
-                            UserId = userId ?? Guid.Empty,
-                            OpenToken = mediaSource.OpenToken
-                        }).ConfigureAwait(false);
+                var openStreamResult = await _mediaInfoHelper.OpenMediaSource(
+                    HttpContext,
+                    new LiveStreamRequest
+                    {
+                        AudioStreamIndex = audioStreamIndex,
+                        DeviceProfile = playbackInfoDto?.DeviceProfile,
+                        EnableDirectPlay = enableDirectPlay.Value,
+                        EnableDirectStream = enableDirectStream.Value,
+                        ItemId = itemId,
+                        MaxAudioChannels = maxAudioChannels,
+                        MaxStreamingBitrate = maxStreamingBitrate,
+                        PlaySessionId = info.PlaySessionId,
+                        StartTimeTicks = startTimeTicks,
+                        SubtitleStreamIndex = subtitleStreamIndex,
+                        UserId = userId ?? Guid.Empty,
+                        OpenToken = mediaSource.OpenToken
+                    }).ConfigureAwait(false);
 
-                    info.MediaSources = new[] { openStreamResult.MediaSource };
-                }
+                info.MediaSources = new[] { openStreamResult.MediaSource };
             }
-
-            return info;
         }
 
-        /// <summary>
-        /// Opens a media source.
-        /// </summary>
-        /// <param name="openToken">The open token.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
-        /// <param name="startTimeTicks">The start time in ticks.</param>
-        /// <param name="audioStreamIndex">The audio stream index.</param>
-        /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
-        /// <param name="maxAudioChannels">The maximum number of audio channels.</param>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="openLiveStreamDto">The open live stream dto.</param>
-        /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
-        /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
-        /// <response code="200">Media source opened.</response>
-        /// <returns>A <see cref="Task"/> containing a <see cref="LiveStreamResponse"/>.</returns>
-        [HttpPost("LiveStreams/Open")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<LiveStreamResponse>> OpenLiveStream(
-            [FromQuery] string? openToken,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] int? maxStreamingBitrate,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] Guid? itemId,
-            [FromBody] OpenLiveStreamDto? openLiveStreamDto,
-            [FromQuery] bool? enableDirectPlay,
-            [FromQuery] bool? enableDirectStream)
+        return info;
+    }
+
+    /// <summary>
+    /// Opens a media source.
+    /// </summary>
+    /// <param name="openToken">The open token.</param>
+    /// <param name="userId">The user id.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
+    /// <param name="startTimeTicks">The start time in ticks.</param>
+    /// <param name="audioStreamIndex">The audio stream index.</param>
+    /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+    /// <param name="maxAudioChannels">The maximum number of audio channels.</param>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="openLiveStreamDto">The open live stream dto.</param>
+    /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
+    /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
+    /// <response code="200">Media source opened.</response>
+    /// <returns>A <see cref="Task"/> containing a <see cref="LiveStreamResponse"/>.</returns>
+    [HttpPost("LiveStreams/Open")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<LiveStreamResponse>> OpenLiveStream(
+        [FromQuery] string? openToken,
+        [FromQuery] Guid? userId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] int? maxStreamingBitrate,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] Guid? itemId,
+        [FromBody] OpenLiveStreamDto? openLiveStreamDto,
+        [FromQuery] bool? enableDirectPlay,
+        [FromQuery] bool? enableDirectStream)
+    {
+        var request = new LiveStreamRequest
         {
-            var request = new LiveStreamRequest
-            {
-                OpenToken = openToken ?? openLiveStreamDto?.OpenToken,
-                UserId = userId ?? openLiveStreamDto?.UserId ?? Guid.Empty,
-                PlaySessionId = playSessionId ?? openLiveStreamDto?.PlaySessionId,
-                MaxStreamingBitrate = maxStreamingBitrate ?? openLiveStreamDto?.MaxStreamingBitrate,
-                StartTimeTicks = startTimeTicks ?? openLiveStreamDto?.StartTimeTicks,
-                AudioStreamIndex = audioStreamIndex ?? openLiveStreamDto?.AudioStreamIndex,
-                SubtitleStreamIndex = subtitleStreamIndex ?? openLiveStreamDto?.SubtitleStreamIndex,
-                MaxAudioChannels = maxAudioChannels ?? openLiveStreamDto?.MaxAudioChannels,
-                ItemId = itemId ?? openLiveStreamDto?.ItemId ?? Guid.Empty,
-                DeviceProfile = openLiveStreamDto?.DeviceProfile,
-                EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true,
-                EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true,
-                DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http }
-            };
-            return await _mediaInfoHelper.OpenMediaSource(HttpContext, request).ConfigureAwait(false);
-        }
+            OpenToken = openToken ?? openLiveStreamDto?.OpenToken,
+            UserId = userId ?? openLiveStreamDto?.UserId ?? Guid.Empty,
+            PlaySessionId = playSessionId ?? openLiveStreamDto?.PlaySessionId,
+            MaxStreamingBitrate = maxStreamingBitrate ?? openLiveStreamDto?.MaxStreamingBitrate,
+            StartTimeTicks = startTimeTicks ?? openLiveStreamDto?.StartTimeTicks,
+            AudioStreamIndex = audioStreamIndex ?? openLiveStreamDto?.AudioStreamIndex,
+            SubtitleStreamIndex = subtitleStreamIndex ?? openLiveStreamDto?.SubtitleStreamIndex,
+            MaxAudioChannels = maxAudioChannels ?? openLiveStreamDto?.MaxAudioChannels,
+            ItemId = itemId ?? openLiveStreamDto?.ItemId ?? Guid.Empty,
+            DeviceProfile = openLiveStreamDto?.DeviceProfile,
+            EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true,
+            EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true,
+            DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http }
+        };
+        return await _mediaInfoHelper.OpenMediaSource(HttpContext, request).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Closes a media source.
-        /// </summary>
-        /// <param name="liveStreamId">The livestream id.</param>
-        /// <response code="204">Livestream closed.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("LiveStreams/Close")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> CloseLiveStream([FromQuery, Required] string liveStreamId)
+    /// <summary>
+    /// Closes a media source.
+    /// </summary>
+    /// <param name="liveStreamId">The livestream id.</param>
+    /// <response code="204">Livestream closed.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("LiveStreams/Close")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> CloseLiveStream([FromQuery, Required] string liveStreamId)
+    {
+        await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Tests the network with a request with the size of the bitrate.
+    /// </summary>
+    /// <param name="size">The bitrate. Defaults to 102400.</param>
+    /// <response code="200">Test buffer returned.</response>
+    /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns>
+    [HttpGet("Playback/BitrateTest")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesFile(MediaTypeNames.Application.Octet)]
+    public ActionResult GetBitrateTestBytes([FromQuery][Range(1, 100_000_000, ErrorMessage = "The requested size must be greater than or equal to {1} and less than or equal to {2}")] int size = 102400)
+    {
+        byte[] buffer = ArrayPool<byte>.Shared.Rent(size);
+        try
         {
-            await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
-            return NoContent();
+            Random.Shared.NextBytes(buffer);
+            return File(buffer, MediaTypeNames.Application.Octet);
         }
-
-        /// <summary>
-        /// Tests the network with a request with the size of the bitrate.
-        /// </summary>
-        /// <param name="size">The bitrate. Defaults to 102400.</param>
-        /// <response code="200">Test buffer returned.</response>
-        /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns>
-        [HttpGet("Playback/BitrateTest")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesFile(MediaTypeNames.Application.Octet)]
-        public ActionResult GetBitrateTestBytes([FromQuery][Range(1, 100_000_000, ErrorMessage = "The requested size must be greater than or equal to {1} and less than or equal to {2}")] int size = 102400)
+        finally
         {
-            byte[] buffer = ArrayPool<byte>.Shared.Rent(size);
-            try
-            {
-                Random.Shared.NextBytes(buffer);
-                return File(buffer, MediaTypeNames.Application.Octet);
-            }
-            finally
-            {
-                ArrayPool<byte>.Shared.Return(buffer);
-            }
+            ArrayPool<byte>.Shared.Return(buffer);
         }
     }
 }

+ 234 - 235
Jellyfin.Api/Controllers/MoviesController.cs

@@ -18,122 +18,122 @@ using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Movies controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class MoviesController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IDtoService _dtoService;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+
     /// <summary>
-    /// Movies controller.
+    /// Initializes a new instance of the <see cref="MoviesController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class MoviesController : BaseJellyfinApiController
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    public MoviesController(
+        IUserManager userManager,
+        ILibraryManager libraryManager,
+        IDtoService dtoService,
+        IServerConfigurationManager serverConfigurationManager)
     {
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="MoviesController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        public MoviesController(
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IDtoService dtoService,
-            IServerConfigurationManager serverConfigurationManager)
-        {
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _dtoService = dtoService;
-            _serverConfigurationManager = serverConfigurationManager;
-        }
-
-        /// <summary>
-        /// Gets movie recommendations.
-        /// </summary>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. The fields to return.</param>
-        /// <param name="categoryLimit">The max number of categories to return.</param>
-        /// <param name="itemLimit">The max number of items to return per category.</param>
-        /// <response code="200">Movie recommendations returned.</response>
-        /// <returns>The list of movie recommendations.</returns>
-        [HttpGet("Recommendations")]
-        public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
-            [FromQuery] Guid? userId,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] int categoryLimit = 5,
-            [FromQuery] int itemLimit = 8)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User);
-
-            var categories = new List<RecommendationDto>();
-
-            var parentIdGuid = parentId ?? Guid.Empty;
+        _userManager = userManager;
+        _libraryManager = libraryManager;
+        _dtoService = dtoService;
+        _serverConfigurationManager = serverConfigurationManager;
+    }
 
-            var query = new InternalItemsQuery(user)
-            {
-                IncludeItemTypes = new[]
-                {
-                    BaseItemKind.Movie,
-                    // nameof(Trailer),
-                    // nameof(LiveTvProgram)
-                },
-                // IsMovie = true
-                OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) },
-                Limit = 7,
-                ParentId = parentIdGuid,
-                Recursive = true,
-                IsPlayed = true,
-                DtoOptions = dtoOptions
-            };
+    /// <summary>
+    /// Gets movie recommendations.
+    /// </summary>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. The fields to return.</param>
+    /// <param name="categoryLimit">The max number of categories to return.</param>
+    /// <param name="itemLimit">The max number of items to return per category.</param>
+    /// <response code="200">Movie recommendations returned.</response>
+    /// <returns>The list of movie recommendations.</returns>
+    [HttpGet("Recommendations")]
+    public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
+        [FromQuery] Guid? userId,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] int categoryLimit = 5,
+        [FromQuery] int itemLimit = 8)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User);
 
-            var recentlyPlayedMovies = _libraryManager.GetItemList(query);
+        var categories = new List<RecommendationDto>();
 
-            var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
-            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
-            {
-                itemTypes.Add(BaseItemKind.Trailer);
-                itemTypes.Add(BaseItemKind.LiveTvProgram);
-            }
+        var parentIdGuid = parentId ?? Guid.Empty;
 
-            var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
+        var query = new InternalItemsQuery(user)
+        {
+            IncludeItemTypes = new[]
             {
-                IncludeItemTypes = itemTypes.ToArray(),
-                IsMovie = true,
-                OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) },
-                Limit = 10,
-                IsFavoriteOrLiked = true,
-                ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
-                EnableGroupByMetadataKey = true,
-                ParentId = parentIdGuid,
-                Recursive = true,
-                DtoOptions = dtoOptions
-            });
-
-            var mostRecentMovies = recentlyPlayedMovies.GetRange(0, Math.Min(recentlyPlayedMovies.Count, 6));
-            // Get recently played directors
-            var recentDirectors = GetDirectors(mostRecentMovies)
-                .ToList();
-
-            // Get recently played actors
-            var recentActors = GetActors(mostRecentMovies)
-                .ToList();
-
-            var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator();
-            var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator();
-
-            var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator();
-            var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator();
+                BaseItemKind.Movie,
+                // nameof(Trailer),
+                // nameof(LiveTvProgram)
+            },
+            // IsMovie = true
+            OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) },
+            Limit = 7,
+            ParentId = parentIdGuid,
+            Recursive = true,
+            IsPlayed = true,
+            DtoOptions = dtoOptions
+        };
+
+        var recentlyPlayedMovies = _libraryManager.GetItemList(query);
+
+        var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
+        if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+        {
+            itemTypes.Add(BaseItemKind.Trailer);
+            itemTypes.Add(BaseItemKind.LiveTvProgram);
+        }
 
-            var categoryTypes = new List<IEnumerator<RecommendationDto>>
+        var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
+        {
+            IncludeItemTypes = itemTypes.ToArray(),
+            IsMovie = true,
+            OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) },
+            Limit = 10,
+            IsFavoriteOrLiked = true,
+            ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
+            EnableGroupByMetadataKey = true,
+            ParentId = parentIdGuid,
+            Recursive = true,
+            DtoOptions = dtoOptions
+        });
+
+        var mostRecentMovies = recentlyPlayedMovies.GetRange(0, Math.Min(recentlyPlayedMovies.Count, 6));
+        // Get recently played directors
+        var recentDirectors = GetDirectors(mostRecentMovies)
+            .ToList();
+
+        // Get recently played actors
+        var recentActors = GetActors(mostRecentMovies)
+            .ToList();
+
+        var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator();
+        var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator();
+
+        var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator();
+        var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator();
+
+        var categoryTypes = new List<IEnumerator<RecommendationDto>>
             {
                 // Give this extra weight
                 similarToRecentlyPlayed,
@@ -146,181 +146,180 @@ namespace Jellyfin.Api.Controllers
                 hasActorFromRecentlyPlayed
             };
 
-            while (categories.Count < categoryLimit)
-            {
-                var allEmpty = true;
+        while (categories.Count < categoryLimit)
+        {
+            var allEmpty = true;
 
-                foreach (var category in categoryTypes)
+            foreach (var category in categoryTypes)
+            {
+                if (category.MoveNext())
                 {
-                    if (category.MoveNext())
-                    {
-                        categories.Add(category.Current);
-                        allEmpty = false;
+                    categories.Add(category.Current);
+                    allEmpty = false;
 
-                        if (categories.Count >= categoryLimit)
-                        {
-                            break;
-                        }
+                    if (categories.Count >= categoryLimit)
+                    {
+                        break;
                     }
                 }
-
-                if (allEmpty)
-                {
-                    break;
-                }
             }
 
-            return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable());
-        }
-
-        private IEnumerable<RecommendationDto> GetWithDirector(
-            User? user,
-            IEnumerable<string> names,
-            int itemLimit,
-            DtoOptions dtoOptions,
-            RecommendationType type)
-        {
-            var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
-            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+            if (allEmpty)
             {
-                itemTypes.Add(BaseItemKind.Trailer);
-                itemTypes.Add(BaseItemKind.LiveTvProgram);
+                break;
             }
+        }
 
-            foreach (var name in names)
-            {
-                var items = _libraryManager.GetItemList(
-                    new InternalItemsQuery(user)
-                    {
-                        Person = name,
-                        // Account for duplicates by IMDb id, since the database doesn't support this yet
-                        Limit = itemLimit + 2,
-                        PersonTypes = new[] { PersonType.Director },
-                        IncludeItemTypes = itemTypes.ToArray(),
-                        IsMovie = true,
-                        EnableGroupByMetadataKey = true,
-                        DtoOptions = dtoOptions
-                    }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
-                    .Take(itemLimit)
-                    .ToList();
-
-                if (items.Count > 0)
-                {
-                    var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
+        return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable());
+    }
 
-                    yield return new RecommendationDto
-                    {
-                        BaselineItemName = name,
-                        CategoryId = name.GetMD5(),
-                        RecommendationType = type,
-                        Items = returnItems
-                    };
-                }
-            }
+    private IEnumerable<RecommendationDto> GetWithDirector(
+        User? user,
+        IEnumerable<string> names,
+        int itemLimit,
+        DtoOptions dtoOptions,
+        RecommendationType type)
+    {
+        var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
+        if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+        {
+            itemTypes.Add(BaseItemKind.Trailer);
+            itemTypes.Add(BaseItemKind.LiveTvProgram);
         }
 
-        private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
+        foreach (var name in names)
         {
-            var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
-            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
-            {
-                itemTypes.Add(BaseItemKind.Trailer);
-                itemTypes.Add(BaseItemKind.LiveTvProgram);
-            }
-
-            foreach (var name in names)
-            {
-                var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
+            var items = _libraryManager.GetItemList(
+                new InternalItemsQuery(user)
                 {
                     Person = name,
                     // Account for duplicates by IMDb id, since the database doesn't support this yet
                     Limit = itemLimit + 2,
+                    PersonTypes = new[] { PersonType.Director },
                     IncludeItemTypes = itemTypes.ToArray(),
                     IsMovie = true,
                     EnableGroupByMetadataKey = true,
                     DtoOptions = dtoOptions
                 }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
-                    .Take(itemLimit)
-                    .ToList();
+                .Take(itemLimit)
+                .ToList();
 
-                if (items.Count > 0)
-                {
-                    var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
+            if (items.Count > 0)
+            {
+                var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
 
-                    yield return new RecommendationDto
-                    {
-                        BaselineItemName = name,
-                        CategoryId = name.GetMD5(),
-                        RecommendationType = type,
-                        Items = returnItems
-                    };
-                }
+                yield return new RecommendationDto
+                {
+                    BaselineItemName = name,
+                    CategoryId = name.GetMD5(),
+                    RecommendationType = type,
+                    Items = returnItems
+                };
             }
         }
+    }
+
+    private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
+    {
+        var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
+        if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+        {
+            itemTypes.Add(BaseItemKind.Trailer);
+            itemTypes.Add(BaseItemKind.LiveTvProgram);
+        }
 
-        private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
+        foreach (var name in names)
         {
-            var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
-            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+            var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
             {
-                itemTypes.Add(BaseItemKind.Trailer);
-                itemTypes.Add(BaseItemKind.LiveTvProgram);
-            }
+                Person = name,
+                // Account for duplicates by IMDb id, since the database doesn't support this yet
+                Limit = itemLimit + 2,
+                IncludeItemTypes = itemTypes.ToArray(),
+                IsMovie = true,
+                EnableGroupByMetadataKey = true,
+                DtoOptions = dtoOptions
+            }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
+                .Take(itemLimit)
+                .ToList();
 
-            foreach (var item in baselineItems)
+            if (items.Count > 0)
             {
-                var similar = _libraryManager.GetItemList(new InternalItemsQuery(user)
-                {
-                    Limit = itemLimit,
-                    IncludeItemTypes = itemTypes.ToArray(),
-                    IsMovie = true,
-                    SimilarTo = item,
-                    EnableGroupByMetadataKey = true,
-                    DtoOptions = dtoOptions
-                });
+                var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
 
-                if (similar.Count > 0)
+                yield return new RecommendationDto
                 {
-                    var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user);
-
-                    yield return new RecommendationDto
-                    {
-                        BaselineItemName = item.Name,
-                        CategoryId = item.Id,
-                        RecommendationType = type,
-                        Items = returnItems
-                    };
-                }
+                    BaselineItemName = name,
+                    CategoryId = name.GetMD5(),
+                    RecommendationType = type,
+                    Items = returnItems
+                };
             }
         }
+    }
 
-        private IEnumerable<string> GetActors(IEnumerable<BaseItem> items)
+    private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
+    {
+        var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
+        if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
         {
-            var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director })
+            itemTypes.Add(BaseItemKind.Trailer);
+            itemTypes.Add(BaseItemKind.LiveTvProgram);
+        }
+
+        foreach (var item in baselineItems)
+        {
+            var similar = _libraryManager.GetItemList(new InternalItemsQuery(user)
             {
-                MaxListOrder = 3
+                Limit = itemLimit,
+                IncludeItemTypes = itemTypes.ToArray(),
+                IsMovie = true,
+                SimilarTo = item,
+                EnableGroupByMetadataKey = true,
+                DtoOptions = dtoOptions
             });
 
-            var itemIds = items.Select(i => i.Id).ToList();
+            if (similar.Count > 0)
+            {
+                var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user);
 
-            return people
-                .Where(i => itemIds.Contains(i.ItemId))
-                .Select(i => i.Name)
-                .DistinctNames();
+                yield return new RecommendationDto
+                {
+                    BaselineItemName = item.Name,
+                    CategoryId = item.Id,
+                    RecommendationType = type,
+                    Items = returnItems
+                };
+            }
         }
+    }
 
-        private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items)
+    private IEnumerable<string> GetActors(IEnumerable<BaseItem> items)
+    {
+        var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director })
         {
-            var people = _libraryManager.GetPeople(new InternalPeopleQuery(
-                new[] { PersonType.Director },
-                Array.Empty<string>()));
+            MaxListOrder = 3
+        });
 
-            var itemIds = items.Select(i => i.Id).ToList();
+        var itemIds = items.Select(i => i.Id).ToList();
 
-            return people
-                .Where(i => itemIds.Contains(i.ItemId))
-                .Select(i => i.Name)
-                .DistinctNames();
-        }
+        return people
+            .Where(i => itemIds.Contains(i.ItemId))
+            .Select(i => i.Name)
+            .DistinctNames();
+    }
+
+    private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items)
+    {
+        var people = _libraryManager.GetPeople(new InternalPeopleQuery(
+            new[] { PersonType.Director },
+            Array.Empty<string>()));
+
+        var itemIds = items.Select(i => i.Id).ToList();
+
+        return people
+            .Where(i => itemIds.Contains(i.ItemId))
+            .Select(i => i.Name)
+            .DistinctNames();
     }
 }

+ 151 - 152
Jellyfin.Api/Controllers/MusicGenresController.cs

@@ -18,181 +18,180 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The music genres controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class MusicGenresController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IDtoService _dtoService;
+    private readonly IUserManager _userManager;
+
     /// <summary>
-    /// The music genres controller.
+    /// Initializes a new instance of the <see cref="MusicGenresController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class MusicGenresController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param>
+    /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
+    public MusicGenresController(
+        ILibraryManager libraryManager,
+        IUserManager userManager,
+        IDtoService dtoService)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-        private readonly IUserManager _userManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="MusicGenresController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param>
-        /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
-        public MusicGenresController(
-            ILibraryManager libraryManager,
-            IUserManager userManager,
-            IDtoService dtoService)
-        {
-            _libraryManager = libraryManager;
-            _userManager = userManager;
-            _dtoService = dtoService;
-        }
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+        _dtoService = dtoService;
+    }
 
-        /// <summary>
-        /// Gets all music genres from a given item, folder, or the entire library.
-        /// </summary>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="searchTerm">The search term.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
-        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
-        /// <response code="200">Music genres returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns>
-        [HttpGet]
-        [Obsolete("Use GetGenres instead")]
-        public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres(
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] string? searchTerm,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery] bool? isFavorite,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery] bool? enableImages = true,
-            [FromQuery] bool enableTotalRecordCount = true)
-        {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
+    /// <summary>
+    /// Gets all music genres from a given item, folder, or the entire library.
+    /// </summary>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="searchTerm">The search term.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+    /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+    /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
+    /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
+    /// <response code="200">Music genres returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns>
+    [HttpGet]
+    [Obsolete("Use GetGenres instead")]
+    public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres(
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] string? searchTerm,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery] bool? isFavorite,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] Guid? userId,
+        [FromQuery] string? nameStartsWithOrGreater,
+        [FromQuery] string? nameStartsWith,
+        [FromQuery] string? nameLessThan,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery] bool? enableImages = true,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
 
-            User? user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+        User? user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-            var parentItem = _libraryManager.GetParentItem(parentId, userId);
+        var parentItem = _libraryManager.GetParentItem(parentId, userId);
 
-            var query = new InternalItemsQuery(user)
+        var query = new InternalItemsQuery(user)
+        {
+            ExcludeItemTypes = excludeItemTypes,
+            IncludeItemTypes = includeItemTypes,
+            StartIndex = startIndex,
+            Limit = limit,
+            IsFavorite = isFavorite,
+            NameLessThan = nameLessThan,
+            NameStartsWith = nameStartsWith,
+            NameStartsWithOrGreater = nameStartsWithOrGreater,
+            DtoOptions = dtoOptions,
+            SearchTerm = searchTerm,
+            EnableTotalRecordCount = enableTotalRecordCount,
+            OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
+        };
+
+        if (parentId.HasValue)
+        {
+            if (parentItem is Folder)
             {
-                ExcludeItemTypes = excludeItemTypes,
-                IncludeItemTypes = includeItemTypes,
-                StartIndex = startIndex,
-                Limit = limit,
-                IsFavorite = isFavorite,
-                NameLessThan = nameLessThan,
-                NameStartsWith = nameStartsWith,
-                NameStartsWithOrGreater = nameStartsWithOrGreater,
-                DtoOptions = dtoOptions,
-                SearchTerm = searchTerm,
-                EnableTotalRecordCount = enableTotalRecordCount,
-                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
-            };
-
-            if (parentId.HasValue)
+                query.AncestorIds = new[] { parentId.Value };
+            }
+            else
             {
-                if (parentItem is Folder)
-                {
-                    query.AncestorIds = new[] { parentId.Value };
-                }
-                else
-                {
-                    query.ItemIds = new[] { parentId.Value };
-                }
+                query.ItemIds = new[] { parentId.Value };
             }
-
-            var result = _libraryManager.GetMusicGenres(query);
-
-            var shouldIncludeItemTypes = includeItemTypes.Length != 0;
-            return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
 
-        /// <summary>
-        /// Gets a music genre, by name.
-        /// </summary>
-        /// <param name="genreName">The genre name.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <returns>An <see cref="OkResult"/> containing a <see cref="BaseItemDto"/> with the music genre.</returns>
-        [HttpGet("{genreName}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<BaseItemDto> GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
-        {
-            var dtoOptions = new DtoOptions().AddClientFields(User);
+        var result = _libraryManager.GetMusicGenres(query);
 
-            MusicGenre? item;
+        var shouldIncludeItemTypes = includeItemTypes.Length != 0;
+        return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
+    }
 
-            if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1)
-            {
-                item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions, BaseItemKind.MusicGenre);
-            }
-            else
-            {
-                item = _libraryManager.GetMusicGenre(genreName);
-            }
+    /// <summary>
+    /// Gets a music genre, by name.
+    /// </summary>
+    /// <param name="genreName">The genre name.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <returns>An <see cref="OkResult"/> containing a <see cref="BaseItemDto"/> with the music genre.</returns>
+    [HttpGet("{genreName}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<BaseItemDto> GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
+    {
+        var dtoOptions = new DtoOptions().AddClientFields(User);
 
-            if (userId.HasValue && !userId.Value.Equals(default))
-            {
-                var user = _userManager.GetUserById(userId.Value);
+        MusicGenre? item;
 
-                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
-            }
+        if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1)
+        {
+            item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions, BaseItemKind.MusicGenre);
+        }
+        else
+        {
+            item = _libraryManager.GetMusicGenre(genreName);
+        }
 
-            return _dtoService.GetBaseItemDto(item, dtoOptions);
+        if (userId.HasValue && !userId.Value.Equals(default))
+        {
+            var user = _userManager.GetUserById(userId.Value);
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
         }
 
-        private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind)
-            where T : BaseItem, new()
+        return _dtoService.GetBaseItemDto(item, dtoOptions);
+    }
+
+    private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind)
+        where T : BaseItem, new()
+    {
+        var result = libraryManager.GetItemList(new InternalItemsQuery
         {
-            var result = libraryManager.GetItemList(new InternalItemsQuery
-            {
-                Name = name.Replace(BaseItem.SlugChar, '&'),
-                IncludeItemTypes = new[] { baseItemKind },
-                DtoOptions = dtoOptions
-            }).OfType<T>().FirstOrDefault();
+            Name = name.Replace(BaseItem.SlugChar, '&'),
+            IncludeItemTypes = new[] { baseItemKind },
+            DtoOptions = dtoOptions
+        }).OfType<T>().FirstOrDefault();
 
-            result ??= libraryManager.GetItemList(new InternalItemsQuery
-            {
-                Name = name.Replace(BaseItem.SlugChar, '/'),
-                IncludeItemTypes = new[] { baseItemKind },
-                DtoOptions = dtoOptions
-            }).OfType<T>().FirstOrDefault();
+        result ??= libraryManager.GetItemList(new InternalItemsQuery
+        {
+            Name = name.Replace(BaseItem.SlugChar, '/'),
+            IncludeItemTypes = new[] { baseItemKind },
+            DtoOptions = dtoOptions
+        }).OfType<T>().FirstOrDefault();
 
-            result ??= libraryManager.GetItemList(new InternalItemsQuery
-            {
-                Name = name.Replace(BaseItem.SlugChar, '?'),
-                IncludeItemTypes = new[] { baseItemKind },
-                DtoOptions = dtoOptions
-            }).OfType<T>().FirstOrDefault();
+        result ??= libraryManager.GetItemList(new InternalItemsQuery
+        {
+            Name = name.Replace(BaseItem.SlugChar, '?'),
+            IncludeItemTypes = new[] { baseItemKind },
+            DtoOptions = dtoOptions
+        }).OfType<T>().FirstOrDefault();
 
-            return result;
-        }
+        return result;
     }
 }

+ 134 - 135
Jellyfin.Api/Controllers/PackageController.cs

@@ -11,157 +11,156 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Package Controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class PackageController : BaseJellyfinApiController
 {
+    private readonly IInstallationManager _installationManager;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+
     /// <summary>
-    /// Package Controller.
+    /// Initializes a new instance of the <see cref="PackageController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class PackageController : BaseJellyfinApiController
+    /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager)
     {
-        private readonly IInstallationManager _installationManager;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="PackageController"/> class.
-        /// </summary>
-        /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager)
-        {
-            _installationManager = installationManager;
-            _serverConfigurationManager = serverConfigurationManager;
-        }
+        _installationManager = installationManager;
+        _serverConfigurationManager = serverConfigurationManager;
+    }
 
-        /// <summary>
-        /// Gets a package by name or assembly GUID.
-        /// </summary>
-        /// <param name="name">The name of the package.</param>
-        /// <param name="assemblyGuid">The GUID of the associated assembly.</param>
-        /// <response code="200">Package retrieved.</response>
-        /// <returns>A <see cref="PackageInfo"/> containing package information.</returns>
-        [HttpGet("Packages/{name}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<PackageInfo>> GetPackageInfo(
-            [FromRoute, Required] string name,
-            [FromQuery] Guid? assemblyGuid)
+    /// <summary>
+    /// Gets a package by name or assembly GUID.
+    /// </summary>
+    /// <param name="name">The name of the package.</param>
+    /// <param name="assemblyGuid">The GUID of the associated assembly.</param>
+    /// <response code="200">Package retrieved.</response>
+    /// <returns>A <see cref="PackageInfo"/> containing package information.</returns>
+    [HttpGet("Packages/{name}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<PackageInfo>> GetPackageInfo(
+        [FromRoute, Required] string name,
+        [FromQuery] Guid? assemblyGuid)
+    {
+        var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
+        var result = _installationManager.FilterPackages(
+                packages,
+                name,
+                assemblyGuid ?? default)
+            .FirstOrDefault();
+
+        if (result is null)
         {
-            var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
-            var result = _installationManager.FilterPackages(
-                    packages,
-                    name,
-                    assemblyGuid ?? default)
-                .FirstOrDefault();
-
-            if (result is null)
-            {
-                return NotFound();
-            }
-
-            return result;
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets available packages.
-        /// </summary>
-        /// <response code="200">Available packages returned.</response>
-        /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns>
-        [HttpGet("Packages")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<IEnumerable<PackageInfo>> GetPackages()
-        {
-            IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
+        return result;
+    }
 
-            return packages;
-        }
+    /// <summary>
+    /// Gets available packages.
+    /// </summary>
+    /// <response code="200">Available packages returned.</response>
+    /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns>
+    [HttpGet("Packages")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<IEnumerable<PackageInfo>> GetPackages()
+    {
+        IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
 
-        /// <summary>
-        /// Installs a package.
-        /// </summary>
-        /// <param name="name">Package name.</param>
-        /// <param name="assemblyGuid">GUID of the associated assembly.</param>
-        /// <param name="version">Optional version. Defaults to latest version.</param>
-        /// <param name="repositoryUrl">Optional. Specify the repository to install from.</param>
-        /// <response code="204">Package found.</response>
-        /// <response code="404">Package not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns>
-        [HttpPost("Packages/Installed/{name}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        public async Task<ActionResult> InstallPackage(
-            [FromRoute, Required] string name,
-            [FromQuery] Guid? assemblyGuid,
-            [FromQuery] string? version,
-            [FromQuery] string? repositoryUrl)
-        {
-            var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
-            if (!string.IsNullOrEmpty(repositoryUrl))
-            {
-                packages = packages.Where(p => p.Versions.Any(q => q.RepositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)))
-                    .ToList();
-            }
-
-            var package = _installationManager.GetCompatibleVersions(
-                    packages,
-                    name,
-                    assemblyGuid ?? Guid.Empty,
-                    specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version))
-                .FirstOrDefault();
-
-            if (package is null)
-            {
-                return NotFound();
-            }
-
-            await _installationManager.InstallPackage(package).ConfigureAwait(false);
-
-            return NoContent();
-        }
+        return packages;
+    }
 
-        /// <summary>
-        /// Cancels a package installation.
-        /// </summary>
-        /// <param name="packageId">Installation Id.</param>
-        /// <response code="204">Installation cancelled.</response>
-        /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns>
-        [HttpDelete("Packages/Installing/{packageId}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult CancelPackageInstallation(
-            [FromRoute, Required] Guid packageId)
+    /// <summary>
+    /// Installs a package.
+    /// </summary>
+    /// <param name="name">Package name.</param>
+    /// <param name="assemblyGuid">GUID of the associated assembly.</param>
+    /// <param name="version">Optional version. Defaults to latest version.</param>
+    /// <param name="repositoryUrl">Optional. Specify the repository to install from.</param>
+    /// <response code="204">Package found.</response>
+    /// <response code="404">Package not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns>
+    [HttpPost("Packages/Installed/{name}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    public async Task<ActionResult> InstallPackage(
+        [FromRoute, Required] string name,
+        [FromQuery] Guid? assemblyGuid,
+        [FromQuery] string? version,
+        [FromQuery] string? repositoryUrl)
+    {
+        var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
+        if (!string.IsNullOrEmpty(repositoryUrl))
         {
-            _installationManager.CancelInstallation(packageId);
-            return NoContent();
+            packages = packages.Where(p => p.Versions.Any(q => q.RepositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)))
+                .ToList();
         }
 
-        /// <summary>
-        /// Gets all package repositories.
-        /// </summary>
-        /// <response code="200">Package repositories returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns>
-        [HttpGet("Repositories")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories()
-        {
-            return Ok(_serverConfigurationManager.Configuration.PluginRepositories.AsEnumerable());
-        }
+        var package = _installationManager.GetCompatibleVersions(
+                packages,
+                name,
+                assemblyGuid ?? Guid.Empty,
+                specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version))
+            .FirstOrDefault();
 
-        /// <summary>
-        /// Sets the enabled and existing package repositories.
-        /// </summary>
-        /// <param name="repositoryInfos">The list of package repositories.</param>
-        /// <response code="204">Package repositories saved.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Repositories")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos)
+        if (package is null)
         {
-            _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos;
-            _serverConfigurationManager.SaveConfiguration();
-            return NoContent();
+            return NotFound();
         }
+
+        await _installationManager.InstallPackage(package).ConfigureAwait(false);
+
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Cancels a package installation.
+    /// </summary>
+    /// <param name="packageId">Installation Id.</param>
+    /// <response code="204">Installation cancelled.</response>
+    /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns>
+    [HttpDelete("Packages/Installing/{packageId}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult CancelPackageInstallation(
+        [FromRoute, Required] Guid packageId)
+    {
+        _installationManager.CancelInstallation(packageId);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Gets all package repositories.
+    /// </summary>
+    /// <response code="200">Package repositories returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns>
+    [HttpGet("Repositories")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories()
+    {
+        return Ok(_serverConfigurationManager.Configuration.PluginRepositories.AsEnumerable());
+    }
+
+    /// <summary>
+    /// Sets the enabled and existing package repositories.
+    /// </summary>
+    /// <param name="repositoryInfos">The list of package repositories.</param>
+    /// <response code="204">Package repositories saved.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Repositories")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos)
+    {
+        _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos;
+        _serverConfigurationManager.SaveConfiguration();
+        return NoContent();
     }
 }

+ 105 - 106
Jellyfin.Api/Controllers/PersonsController.cs

@@ -15,125 +15,124 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Persons controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class PersonsController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IDtoService _dtoService;
+    private readonly IUserManager _userManager;
+
     /// <summary>
-    /// Persons controller.
+    /// Initializes a new instance of the <see cref="PersonsController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class PersonsController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    public PersonsController(
+        ILibraryManager libraryManager,
+        IDtoService dtoService,
+        IUserManager userManager)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-        private readonly IUserManager _userManager;
+        _libraryManager = libraryManager;
+        _dtoService = dtoService;
+        _userManager = userManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="PersonsController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        public PersonsController(
-            ILibraryManager libraryManager,
-            IDtoService dtoService,
-            IUserManager userManager)
-        {
-            _libraryManager = libraryManager;
-            _dtoService = dtoService;
-            _userManager = userManager;
-        }
+    /// <summary>
+    /// Gets all persons.
+    /// </summary>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="searchTerm">The search term.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param>
+    /// <param name="enableUserData">Optional, include user data.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param>
+    /// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param>
+    /// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <response code="200">Persons returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetPersons(
+        [FromQuery] int? limit,
+        [FromQuery] string? searchTerm,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery] bool? isFavorite,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+        [FromQuery] Guid? appearsInItemId,
+        [FromQuery] Guid? userId,
+        [FromQuery] bool? enableImages = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+        User? user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-        /// <summary>
-        /// Gets all persons.
-        /// </summary>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="searchTerm">The search term.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param>
-        /// <param name="enableUserData">Optional, include user data.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <response code="200">Persons returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetPersons(
-            [FromQuery] int? limit,
-            [FromQuery] string? searchTerm,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
-            [FromQuery] bool? isFavorite,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-            [FromQuery] Guid? appearsInItemId,
-            [FromQuery] Guid? userId,
-            [FromQuery] bool? enableImages = true)
+        var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite);
+        var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery(
+            personTypes,
+            excludePersonTypes)
         {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+            NameContains = searchTerm,
+            User = user,
+            IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite,
+            AppearsInItemId = appearsInItemId ?? Guid.Empty,
+            Limit = limit ?? 0
+        });
 
-            User? user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+        return new QueryResult<BaseItemDto>(
+            peopleItems
+            .Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user))
+            .ToArray());
+    }
 
-            var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite);
-            var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery(
-                personTypes,
-                excludePersonTypes)
-            {
-                NameContains = searchTerm,
-                User = user,
-                IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite,
-                AppearsInItemId = appearsInItemId ?? Guid.Empty,
-                Limit = limit ?? 0
-            });
+    /// <summary>
+    /// Get person by name.
+    /// </summary>
+    /// <param name="name">Person name.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <response code="200">Person returned.</response>
+    /// <response code="404">Person not found.</response>
+    /// <returns>An <see cref="OkResult"/> containing the person on success,
+    /// or a <see cref="NotFoundResult"/> if person not found.</returns>
+    [HttpGet("{name}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<BaseItemDto> GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId)
+    {
+        var dtoOptions = new DtoOptions()
+            .AddClientFields(User);
 
-            return new QueryResult<BaseItemDto>(
-                peopleItems
-                .Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user))
-                .ToArray());
+        var item = _libraryManager.GetPerson(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get person by name.
-        /// </summary>
-        /// <param name="name">Person name.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <response code="200">Person returned.</response>
-        /// <response code="404">Person not found.</response>
-        /// <returns>An <see cref="OkResult"/> containing the person on success,
-        /// or a <see cref="NotFoundResult"/> if person not found.</returns>
-        [HttpGet("{name}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<BaseItemDto> GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId)
+        if (userId.HasValue && !userId.Value.Equals(default))
         {
-            var dtoOptions = new DtoOptions()
-                .AddClientFields(User);
-
-            var item = _libraryManager.GetPerson(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
-
-            if (userId.HasValue && !userId.Value.Equals(default))
-            {
-                var user = _userManager.GetUserById(userId.Value);
-                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
-            }
-
-            return _dtoService.GetBaseItemDto(item, dtoOptions);
+            var user = _userManager.GetUserById(userId.Value);
+            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
         }
+
+        return _dtoService.GetBaseItemDto(item, dtoOptions);
     }
 }

+ 173 - 174
Jellyfin.Api/Controllers/PlaylistsController.cs

@@ -20,202 +20,201 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.ModelBinding;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Playlists controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class PlaylistsController : BaseJellyfinApiController
 {
+    private readonly IPlaylistManager _playlistManager;
+    private readonly IDtoService _dtoService;
+    private readonly IUserManager _userManager;
+    private readonly ILibraryManager _libraryManager;
+
     /// <summary>
-    /// Playlists controller.
+    /// Initializes a new instance of the <see cref="PlaylistsController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class PlaylistsController : BaseJellyfinApiController
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    public PlaylistsController(
+        IDtoService dtoService,
+        IPlaylistManager playlistManager,
+        IUserManager userManager,
+        ILibraryManager libraryManager)
     {
-        private readonly IPlaylistManager _playlistManager;
-        private readonly IDtoService _dtoService;
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="PlaylistsController"/> class.
-        /// </summary>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        public PlaylistsController(
-            IDtoService dtoService,
-            IPlaylistManager playlistManager,
-            IUserManager userManager,
-            ILibraryManager libraryManager)
-        {
-            _dtoService = dtoService;
-            _playlistManager = playlistManager;
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-        }
+        _dtoService = dtoService;
+        _playlistManager = playlistManager;
+        _userManager = userManager;
+        _libraryManager = libraryManager;
+    }
 
-        /// <summary>
-        /// Creates a new playlist.
-        /// </summary>
-        /// <remarks>
-        /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.
-        /// Query parameters are obsolete.
-        /// </remarks>
-        /// <param name="name">The playlist name.</param>
-        /// <param name="ids">The item ids.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="mediaType">The media type.</param>
-        /// <param name="createPlaylistRequest">The create playlist payload.</param>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist.
-        /// The task result contains an <see cref="OkResult"/> indicating success.
-        /// </returns>
-        [HttpPost]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
-            [FromQuery, ParameterObsolete] string? name,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList<Guid> ids,
-            [FromQuery, ParameterObsolete] Guid? userId,
-            [FromQuery, ParameterObsolete] string? mediaType,
-            [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest)
+    /// <summary>
+    /// Creates a new playlist.
+    /// </summary>
+    /// <remarks>
+    /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.
+    /// Query parameters are obsolete.
+    /// </remarks>
+    /// <param name="name">The playlist name.</param>
+    /// <param name="ids">The item ids.</param>
+    /// <param name="userId">The user id.</param>
+    /// <param name="mediaType">The media type.</param>
+    /// <param name="createPlaylistRequest">The create playlist payload.</param>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist.
+    /// The task result contains an <see cref="OkResult"/> indicating success.
+    /// </returns>
+    [HttpPost]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
+        [FromQuery, ParameterObsolete] string? name,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList<Guid> ids,
+        [FromQuery, ParameterObsolete] Guid? userId,
+        [FromQuery, ParameterObsolete] string? mediaType,
+        [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest)
+    {
+        if (ids.Count == 0)
         {
-            if (ids.Count == 0)
-            {
-                ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>();
-            }
-
-            var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
-            {
-                Name = name ?? createPlaylistRequest?.Name,
-                ItemIdList = ids,
-                UserId = userId ?? createPlaylistRequest?.UserId ?? default,
-                MediaType = mediaType ?? createPlaylistRequest?.MediaType
-            }).ConfigureAwait(false);
-
-            return result;
+            ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>();
         }
 
-        /// <summary>
-        /// Adds items to a playlist.
-        /// </summary>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <param name="ids">Item id, comma delimited.</param>
-        /// <param name="userId">The userId.</param>
-        /// <response code="204">Items added to playlist.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success.</returns>
-        [HttpPost("{playlistId}/Items")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> AddToPlaylist(
-            [FromRoute, Required] Guid playlistId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
-            [FromQuery] Guid? userId)
+        var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
         {
-            await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false);
-            return NoContent();
-        }
+            Name = name ?? createPlaylistRequest?.Name,
+            ItemIdList = ids,
+            UserId = userId ?? createPlaylistRequest?.UserId ?? default,
+            MediaType = mediaType ?? createPlaylistRequest?.MediaType
+        }).ConfigureAwait(false);
 
-        /// <summary>
-        /// Moves a playlist item.
-        /// </summary>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="newIndex">The new index.</param>
-        /// <response code="204">Item moved to new index.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success.</returns>
-        [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> MoveItem(
-            [FromRoute, Required] string playlistId,
-            [FromRoute, Required] string itemId,
-            [FromRoute, Required] int newIndex)
-        {
-            await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false);
-            return NoContent();
-        }
+        return result;
+    }
 
-        /// <summary>
-        /// Removes items from a playlist.
-        /// </summary>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <param name="entryIds">The item ids, comma delimited.</param>
-        /// <response code="204">Items removed.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success.</returns>
-        [HttpDelete("{playlistId}/Items")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> RemoveFromPlaylist(
-            [FromRoute, Required] string playlistId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds)
-        {
-            await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
-            return NoContent();
-        }
+    /// <summary>
+    /// Adds items to a playlist.
+    /// </summary>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="ids">Item id, comma delimited.</param>
+    /// <param name="userId">The userId.</param>
+    /// <response code="204">Items added to playlist.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success.</returns>
+    [HttpPost("{playlistId}/Items")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> AddToPlaylist(
+        [FromRoute, Required] Guid playlistId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+        [FromQuery] Guid? userId)
+    {
+        await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Gets the original items of a playlist.
-        /// </summary>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Original playlist returned.</response>
-        /// <response code="404">Playlist not found.</response>
-        /// <returns>The original playlist items.</returns>
-        [HttpGet("{playlistId}/Items")]
-        public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems(
-            [FromRoute, Required] Guid playlistId,
-            [FromQuery, Required] Guid userId,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
-            if (playlist is null)
-            {
-                return NotFound();
-            }
+    /// <summary>
+    /// Moves a playlist item.
+    /// </summary>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="newIndex">The new index.</param>
+    /// <response code="204">Item moved to new index.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success.</returns>
+    [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> MoveItem(
+        [FromRoute, Required] string playlistId,
+        [FromRoute, Required] string itemId,
+        [FromRoute, Required] int newIndex)
+    {
+        await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            var user = userId.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId);
+    /// <summary>
+    /// Removes items from a playlist.
+    /// </summary>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="entryIds">The item ids, comma delimited.</param>
+    /// <response code="204">Items removed.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success.</returns>
+    [HttpDelete("{playlistId}/Items")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> RemoveFromPlaylist(
+        [FromRoute, Required] string playlistId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds)
+    {
+        await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            var items = playlist.GetManageableItems().ToArray();
+    /// <summary>
+    /// Gets the original items of a playlist.
+    /// </summary>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Original playlist returned.</response>
+    /// <response code="404">Playlist not found.</response>
+    /// <returns>The original playlist items.</returns>
+    [HttpGet("{playlistId}/Items")]
+    public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems(
+        [FromRoute, Required] Guid playlistId,
+        [FromQuery, Required] Guid userId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
+        if (playlist is null)
+        {
+            return NotFound();
+        }
 
-            var count = items.Length;
+        var user = userId.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId);
 
-            if (startIndex.HasValue)
-            {
-                items = items.Skip(startIndex.Value).ToArray();
-            }
+        var items = playlist.GetManageableItems().ToArray();
 
-            if (limit.HasValue)
-            {
-                items = items.Take(limit.Value).ToArray();
-            }
+        var count = items.Length;
 
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        if (startIndex.HasValue)
+        {
+            items = items.Skip(startIndex.Value).ToArray();
+        }
 
-            var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user);
+        if (limit.HasValue)
+        {
+            items = items.Take(limit.Value).ToArray();
+        }
 
-            for (int index = 0; index < dtos.Count; index++)
-            {
-                dtos[index].PlaylistItemId = items[index].Item1.Id;
-            }
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-            var result = new QueryResult<BaseItemDto>(
-                startIndex,
-                count,
-                dtos);
+        var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user);
 
-            return result;
+        for (int index = 0; index < dtos.Count; index++)
+        {
+            dtos[index].PlaylistItemId = items[index].Item1.Id;
         }
+
+        var result = new QueryResult<BaseItemDto>(
+            startIndex,
+            count,
+            dtos);
+
+        return result;
     }
 }

+ 317 - 318
Jellyfin.Api/Controllers/PlaystateController.cs

@@ -17,366 +17,365 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Playstate controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class PlaystateController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly IUserDataManager _userDataRepository;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ISessionManager _sessionManager;
+    private readonly ILogger<PlaystateController> _logger;
+    private readonly TranscodingJobHelper _transcodingJobHelper;
+
     /// <summary>
-    /// Playstate controller.
+    /// Initializes a new instance of the <see cref="PlaystateController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class PlaystateController : BaseJellyfinApiController
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
+    /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+    /// <param name="transcodingJobHelper">Th <see cref="TranscodingJobHelper"/> singleton.</param>
+    public PlaystateController(
+        IUserManager userManager,
+        IUserDataManager userDataRepository,
+        ILibraryManager libraryManager,
+        ISessionManager sessionManager,
+        ILoggerFactory loggerFactory,
+        TranscodingJobHelper transcodingJobHelper)
     {
-        private readonly IUserManager _userManager;
-        private readonly IUserDataManager _userDataRepository;
-        private readonly ILibraryManager _libraryManager;
-        private readonly ISessionManager _sessionManager;
-        private readonly ILogger<PlaystateController> _logger;
-        private readonly TranscodingJobHelper _transcodingJobHelper;
+        _userManager = userManager;
+        _userDataRepository = userDataRepository;
+        _libraryManager = libraryManager;
+        _sessionManager = sessionManager;
+        _logger = loggerFactory.CreateLogger<PlaystateController>();
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="PlaystateController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
-        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
-        /// <param name="transcodingJobHelper">Th <see cref="TranscodingJobHelper"/> singleton.</param>
-        public PlaystateController(
-            IUserManager userManager,
-            IUserDataManager userDataRepository,
-            ILibraryManager libraryManager,
-            ISessionManager sessionManager,
-            ILoggerFactory loggerFactory,
-            TranscodingJobHelper transcodingJobHelper)
-        {
-            _userManager = userManager;
-            _userDataRepository = userDataRepository;
-            _libraryManager = libraryManager;
-            _sessionManager = sessionManager;
-            _logger = loggerFactory.CreateLogger<PlaystateController>();
+        _transcodingJobHelper = transcodingJobHelper;
+    }
 
-            _transcodingJobHelper = transcodingJobHelper;
+    /// <summary>
+    /// Marks an item as played for user.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="datePlayed">Optional. The date the item was played.</param>
+    /// <response code="200">Item marked as played.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
+    [HttpPost("Users/{userId}/PlayedItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<UserItemDataDto>> MarkPlayedItem(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId,
+        [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
+    {
+        var user = _userManager.GetUserById(userId);
+        var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Marks an item as played for user.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="datePlayed">Optional. The date the item was played.</param>
-        /// <response code="200">Item marked as played.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
-        [HttpPost("Users/{userId}/PlayedItems/{itemId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult<UserItemDataDto>> MarkPlayedItem(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] Guid itemId,
-            [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
+        var dto = UpdatePlayedStatus(user, item, true, datePlayed);
+        foreach (var additionalUserInfo in session.AdditionalUsers)
         {
-            var user = _userManager.GetUserById(userId);
-            var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+            var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
+            UpdatePlayedStatus(additionalUser, item, true, datePlayed);
+        }
 
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return dto;
+    }
 
-            var dto = UpdatePlayedStatus(user, item, true, datePlayed);
-            foreach (var additionalUserInfo in session.AdditionalUsers)
-            {
-                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
-                UpdatePlayedStatus(additionalUser, item, true, datePlayed);
-            }
+    /// <summary>
+    /// Marks an item as unplayed for user.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Item marked as unplayed.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
+    [HttpDelete("Users/{userId}/PlayedItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<UserItemDataDto>> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    {
+        var user = _userManager.GetUserById(userId);
+        var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var item = _libraryManager.GetItemById(itemId);
 
-            return dto;
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Marks an item as unplayed for user.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">Item marked as unplayed.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
-        [HttpDelete("Users/{userId}/PlayedItems/{itemId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult<UserItemDataDto>> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+        var dto = UpdatePlayedStatus(user, item, false, null);
+        foreach (var additionalUserInfo in session.AdditionalUsers)
         {
-            var user = _userManager.GetUserById(userId);
-            var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var item = _libraryManager.GetItemById(itemId);
+            var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
+            UpdatePlayedStatus(additionalUser, item, false, null);
+        }
 
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return dto;
+    }
 
-            var dto = UpdatePlayedStatus(user, item, false, null);
-            foreach (var additionalUserInfo in session.AdditionalUsers)
-            {
-                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
-                UpdatePlayedStatus(additionalUser, item, false, null);
-            }
+    /// <summary>
+    /// Reports playback has started within a session.
+    /// </summary>
+    /// <param name="playbackStartInfo">The playback start info.</param>
+    /// <response code="204">Playback start recorded.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/Playing")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo)
+    {
+        playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
+        playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            return dto;
-        }
+    /// <summary>
+    /// Reports playback progress within a session.
+    /// </summary>
+    /// <param name="playbackProgressInfo">The playback progress info.</param>
+    /// <response code="204">Playback progress recorded.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/Playing/Progress")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo)
+    {
+        playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
+        playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Reports playback has started within a session.
-        /// </summary>
-        /// <param name="playbackStartInfo">The playback start info.</param>
-        /// <response code="204">Playback start recorded.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/Playing")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo)
-        {
-            playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
-            playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
-            return NoContent();
-        }
+    /// <summary>
+    /// Pings a playback session.
+    /// </summary>
+    /// <param name="playSessionId">Playback session id.</param>
+    /// <response code="204">Playback session pinged.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/Playing/Ping")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult PingPlaybackSession([FromQuery, Required] string playSessionId)
+    {
+        _transcodingJobHelper.PingTranscodingJob(playSessionId, null);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Reports playback progress within a session.
-        /// </summary>
-        /// <param name="playbackProgressInfo">The playback progress info.</param>
-        /// <response code="204">Playback progress recorded.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/Playing/Progress")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo)
+    /// <summary>
+    /// Reports playback has stopped within a session.
+    /// </summary>
+    /// <param name="playbackStopInfo">The playback stop info.</param>
+    /// <response code="204">Playback stop recorded.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/Playing/Stopped")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo)
+    {
+        _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
+        if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
         {
-            playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
-            playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
-            return NoContent();
+            await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
         }
 
-        /// <summary>
-        /// Pings a playback session.
-        /// </summary>
-        /// <param name="playSessionId">Playback session id.</param>
-        /// <response code="204">Playback session pinged.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/Playing/Ping")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult PingPlaybackSession([FromQuery, Required] string playSessionId)
-        {
-            _transcodingJobHelper.PingTranscodingJob(playSessionId, null);
-            return NoContent();
-        }
+        playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Reports playback has stopped within a session.
-        /// </summary>
-        /// <param name="playbackStopInfo">The playback stop info.</param>
-        /// <response code="204">Playback stop recorded.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/Playing/Stopped")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo)
+    /// <summary>
+    /// Reports that a user has begun playing an item.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="mediaSourceId">The id of the MediaSource.</param>
+    /// <param name="audioStreamIndex">The audio stream index.</param>
+    /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+    /// <param name="playMethod">The play method.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="canSeek">Indicates if the client can seek.</param>
+    /// <response code="204">Play start recorded.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Users/{userId}/PlayingItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+    public async Task<ActionResult> OnPlaybackStart(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] PlayMethod? playMethod,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] bool canSeek = false)
+    {
+        var playbackStartInfo = new PlaybackStartInfo
         {
-            _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
-            if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
-            {
-                await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
-            }
+            CanSeek = canSeek,
+            ItemId = itemId,
+            MediaSourceId = mediaSourceId,
+            AudioStreamIndex = audioStreamIndex,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            PlayMethod = playMethod ?? PlayMethod.Transcode,
+            PlaySessionId = playSessionId,
+            LiveStreamId = liveStreamId
+        };
 
-            playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
-            return NoContent();
-        }
+        playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
+        playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Reports that a user has begun playing an item.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="mediaSourceId">The id of the MediaSource.</param>
-        /// <param name="audioStreamIndex">The audio stream index.</param>
-        /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
-        /// <param name="playMethod">The play method.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="canSeek">Indicates if the client can seek.</param>
-        /// <response code="204">Play start recorded.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Users/{userId}/PlayingItems/{itemId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
-        public async Task<ActionResult> OnPlaybackStart(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] PlayMethod? playMethod,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] bool canSeek = false)
+    /// <summary>
+    /// Reports a user's playback progress.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="mediaSourceId">The id of the MediaSource.</param>
+    /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="audioStreamIndex">The audio stream index.</param>
+    /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+    /// <param name="volumeLevel">Scale of 0-100.</param>
+    /// <param name="playMethod">The play method.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="repeatMode">The repeat mode.</param>
+    /// <param name="isPaused">Indicates if the player is paused.</param>
+    /// <param name="isMuted">Indicates if the player is muted.</param>
+    /// <response code="204">Play progress recorded.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+    public async Task<ActionResult> OnPlaybackProgress(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] long? positionTicks,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] int? volumeLevel,
+        [FromQuery] PlayMethod? playMethod,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] RepeatMode? repeatMode,
+        [FromQuery] bool isPaused = false,
+        [FromQuery] bool isMuted = false)
+    {
+        var playbackProgressInfo = new PlaybackProgressInfo
         {
-            var playbackStartInfo = new PlaybackStartInfo
-            {
-                CanSeek = canSeek,
-                ItemId = itemId,
-                MediaSourceId = mediaSourceId,
-                AudioStreamIndex = audioStreamIndex,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                PlayMethod = playMethod ?? PlayMethod.Transcode,
-                PlaySessionId = playSessionId,
-                LiveStreamId = liveStreamId
-            };
+            ItemId = itemId,
+            PositionTicks = positionTicks,
+            IsMuted = isMuted,
+            IsPaused = isPaused,
+            MediaSourceId = mediaSourceId,
+            AudioStreamIndex = audioStreamIndex,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            VolumeLevel = volumeLevel,
+            PlayMethod = playMethod ?? PlayMethod.Transcode,
+            PlaySessionId = playSessionId,
+            LiveStreamId = liveStreamId,
+            RepeatMode = repeatMode ?? RepeatMode.RepeatNone
+        };
 
-            playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
-            playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
-            return NoContent();
-        }
+        playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
+        playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Reports a user's playback progress.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="mediaSourceId">The id of the MediaSource.</param>
-        /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="audioStreamIndex">The audio stream index.</param>
-        /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
-        /// <param name="volumeLevel">Scale of 0-100.</param>
-        /// <param name="playMethod">The play method.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="repeatMode">The repeat mode.</param>
-        /// <param name="isPaused">Indicates if the player is paused.</param>
-        /// <param name="isMuted">Indicates if the player is muted.</param>
-        /// <response code="204">Play progress recorded.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
-        public async Task<ActionResult> OnPlaybackProgress(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] long? positionTicks,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] int? volumeLevel,
-            [FromQuery] PlayMethod? playMethod,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] RepeatMode? repeatMode,
-            [FromQuery] bool isPaused = false,
-            [FromQuery] bool isMuted = false)
+    /// <summary>
+    /// Reports that a user has stopped playing an item.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="mediaSourceId">The id of the MediaSource.</param>
+    /// <param name="nextMediaType">The next media type that will play.</param>
+    /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <response code="204">Playback stop recorded.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Users/{userId}/PlayingItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+    public async Task<ActionResult> OnPlaybackStopped(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? nextMediaType,
+        [FromQuery] long? positionTicks,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] string? playSessionId)
+    {
+        var playbackStopInfo = new PlaybackStopInfo
         {
-            var playbackProgressInfo = new PlaybackProgressInfo
-            {
-                ItemId = itemId,
-                PositionTicks = positionTicks,
-                IsMuted = isMuted,
-                IsPaused = isPaused,
-                MediaSourceId = mediaSourceId,
-                AudioStreamIndex = audioStreamIndex,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                VolumeLevel = volumeLevel,
-                PlayMethod = playMethod ?? PlayMethod.Transcode,
-                PlaySessionId = playSessionId,
-                LiveStreamId = liveStreamId,
-                RepeatMode = repeatMode ?? RepeatMode.RepeatNone
-            };
-
-            playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
-            playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
-            return NoContent();
-        }
+            ItemId = itemId,
+            PositionTicks = positionTicks,
+            MediaSourceId = mediaSourceId,
+            PlaySessionId = playSessionId,
+            LiveStreamId = liveStreamId,
+            NextMediaType = nextMediaType
+        };
 
-        /// <summary>
-        /// Reports that a user has stopped playing an item.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="mediaSourceId">The id of the MediaSource.</param>
-        /// <param name="nextMediaType">The next media type that will play.</param>
-        /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <response code="204">Playback stop recorded.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Users/{userId}/PlayingItems/{itemId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
-        public async Task<ActionResult> OnPlaybackStopped(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? nextMediaType,
-            [FromQuery] long? positionTicks,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] string? playSessionId)
+        _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
+        if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
         {
-            var playbackStopInfo = new PlaybackStopInfo
-            {
-                ItemId = itemId,
-                PositionTicks = positionTicks,
-                MediaSourceId = mediaSourceId,
-                PlaySessionId = playSessionId,
-                LiveStreamId = liveStreamId,
-                NextMediaType = nextMediaType
-            };
+            await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
+        }
 
-            _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
-            if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
-            {
-                await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
-            }
+        playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
-            return NoContent();
+    /// <summary>
+    /// Updates the played status.
+    /// </summary>
+    /// <param name="user">The user.</param>
+    /// <param name="item">The item.</param>
+    /// <param name="wasPlayed">if set to <c>true</c> [was played].</param>
+    /// <param name="datePlayed">The date played.</param>
+    /// <returns>Task.</returns>
+    private UserItemDataDto UpdatePlayedStatus(User user, BaseItem item, bool wasPlayed, DateTime? datePlayed)
+    {
+        if (wasPlayed)
+        {
+            item.MarkPlayed(user, datePlayed, true);
         }
-
-        /// <summary>
-        /// Updates the played status.
-        /// </summary>
-        /// <param name="user">The user.</param>
-        /// <param name="item">The item.</param>
-        /// <param name="wasPlayed">if set to <c>true</c> [was played].</param>
-        /// <param name="datePlayed">The date played.</param>
-        /// <returns>Task.</returns>
-        private UserItemDataDto UpdatePlayedStatus(User user, BaseItem item, bool wasPlayed, DateTime? datePlayed)
+        else
         {
-            if (wasPlayed)
-            {
-                item.MarkPlayed(user, datePlayed, true);
-            }
-            else
-            {
-                item.MarkUnplayed(user);
-            }
-
-            return _userDataRepository.GetUserDataDto(item, user);
+            item.MarkUnplayed(user);
         }
 
-        private PlayMethod ValidatePlayMethod(PlayMethod method, string? playSessionId)
+        return _userDataRepository.GetUserDataDto(item, user);
+    }
+
+    private PlayMethod ValidatePlayMethod(PlayMethod method, string? playSessionId)
+    {
+        if (method == PlayMethod.Transcode)
         {
-            if (method == PlayMethod.Transcode)
+            var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId);
+            if (job is null)
             {
-                var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId);
-                if (job is null)
-                {
-                    return PlayMethod.DirectPlay;
-                }
+                return PlayMethod.DirectPlay;
             }
-
-            return method;
         }
+
+        return method;
     }
 }

+ 205 - 206
Jellyfin.Api/Controllers/PluginsController.cs

@@ -17,250 +17,249 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Plugins controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class PluginsController : BaseJellyfinApiController
 {
+    private readonly IInstallationManager _installationManager;
+    private readonly IPluginManager _pluginManager;
+    private readonly JsonSerializerOptions _serializerOptions;
+
     /// <summary>
-    /// Plugins controller.
+    /// Initializes a new instance of the <see cref="PluginsController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class PluginsController : BaseJellyfinApiController
+    /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
+    /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param>
+    public PluginsController(
+        IInstallationManager installationManager,
+        IPluginManager pluginManager)
     {
-        private readonly IInstallationManager _installationManager;
-        private readonly IPluginManager _pluginManager;
-        private readonly JsonSerializerOptions _serializerOptions;
+        _installationManager = installationManager;
+        _pluginManager = pluginManager;
+        _serializerOptions = JsonDefaults.Options;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="PluginsController"/> class.
-        /// </summary>
-        /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
-        /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param>
-        public PluginsController(
-            IInstallationManager installationManager,
-            IPluginManager pluginManager)
+    /// <summary>
+    /// Gets a list of currently installed plugins.
+    /// </summary>
+    /// <response code="200">Installed plugins returned.</response>
+    /// <returns>List of currently installed plugins.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<PluginInfo>> GetPlugins()
+    {
+        return Ok(_pluginManager.Plugins
+            .OrderBy(p => p.Name)
+            .Select(p => p.GetPluginInfo()));
+    }
+
+    /// <summary>
+    /// Enables a disabled plugin.
+    /// </summary>
+    /// <param name="pluginId">Plugin id.</param>
+    /// <param name="version">Plugin version.</param>
+    /// <response code="204">Plugin enabled.</response>
+    /// <response code="404">Plugin not found.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
+    [HttpPost("{pluginId}/{version}/Enable")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
+    {
+        var plugin = _pluginManager.GetPlugin(pluginId, version);
+        if (plugin is null)
         {
-            _installationManager = installationManager;
-            _pluginManager = pluginManager;
-            _serializerOptions = JsonDefaults.Options;
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets a list of currently installed plugins.
-        /// </summary>
-        /// <response code="200">Installed plugins returned.</response>
-        /// <returns>List of currently installed plugins.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<PluginInfo>> GetPlugins()
+        _pluginManager.EnablePlugin(plugin);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Disable a plugin.
+    /// </summary>
+    /// <param name="pluginId">Plugin id.</param>
+    /// <param name="version">Plugin version.</param>
+    /// <response code="204">Plugin disabled.</response>
+    /// <response code="404">Plugin not found.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
+    [HttpPost("{pluginId}/{version}/Disable")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
+    {
+        var plugin = _pluginManager.GetPlugin(pluginId, version);
+        if (plugin is null)
         {
-            return Ok(_pluginManager.Plugins
-                .OrderBy(p => p.Name)
-                .Select(p => p.GetPluginInfo()));
+            return NotFound();
         }
 
-        /// <summary>
-        /// Enables a disabled plugin.
-        /// </summary>
-        /// <param name="pluginId">Plugin id.</param>
-        /// <param name="version">Plugin version.</param>
-        /// <response code="204">Plugin enabled.</response>
-        /// <response code="404">Plugin not found.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
-        [HttpPost("{pluginId}/{version}/Enable")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
-        {
-            var plugin = _pluginManager.GetPlugin(pluginId, version);
-            if (plugin is null)
-            {
-                return NotFound();
-            }
+        _pluginManager.DisablePlugin(plugin);
+        return NoContent();
+    }
 
-            _pluginManager.EnablePlugin(plugin);
-            return NoContent();
+    /// <summary>
+    /// Uninstalls a plugin by version.
+    /// </summary>
+    /// <param name="pluginId">Plugin id.</param>
+    /// <param name="version">Plugin version.</param>
+    /// <response code="204">Plugin uninstalled.</response>
+    /// <response code="404">Plugin not found.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
+    [HttpDelete("{pluginId}/{version}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
+    {
+        var plugin = _pluginManager.GetPlugin(pluginId, version);
+        if (plugin is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Disable a plugin.
-        /// </summary>
-        /// <param name="pluginId">Plugin id.</param>
-        /// <param name="version">Plugin version.</param>
-        /// <response code="204">Plugin disabled.</response>
-        /// <response code="404">Plugin not found.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
-        [HttpPost("{pluginId}/{version}/Disable")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
-        {
-            var plugin = _pluginManager.GetPlugin(pluginId, version);
-            if (plugin is null)
-            {
-                return NotFound();
-            }
+        _installationManager.UninstallPlugin(plugin);
+        return NoContent();
+    }
 
-            _pluginManager.DisablePlugin(plugin);
-            return NoContent();
-        }
+    /// <summary>
+    /// Uninstalls a plugin.
+    /// </summary>
+    /// <param name="pluginId">Plugin id.</param>
+    /// <response code="204">Plugin uninstalled.</response>
+    /// <response code="404">Plugin not found.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
+    [HttpDelete("{pluginId}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Obsolete("Please use the UninstallPluginByVersion API.")]
+    public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId)
+    {
+        // If no version is given, return the current instance.
+        var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId)).ToList();
 
-        /// <summary>
-        /// Uninstalls a plugin by version.
-        /// </summary>
-        /// <param name="pluginId">Plugin id.</param>
-        /// <param name="version">Plugin version.</param>
-        /// <response code="204">Plugin uninstalled.</response>
-        /// <response code="404">Plugin not found.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
-        [HttpDelete("{pluginId}/{version}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
-        {
-            var plugin = _pluginManager.GetPlugin(pluginId, version);
-            if (plugin is null)
-            {
-                return NotFound();
-            }
+        // Select the un-instanced one first.
+        var plugin = plugins.FirstOrDefault(p => p.Instance is null) ?? plugins.OrderBy(p => p.Manifest.Status).FirstOrDefault();
 
+        if (plugin is not null)
+        {
             _installationManager.UninstallPlugin(plugin);
             return NoContent();
         }
 
-        /// <summary>
-        /// Uninstalls a plugin.
-        /// </summary>
-        /// <param name="pluginId">Plugin id.</param>
-        /// <response code="204">Plugin uninstalled.</response>
-        /// <response code="404">Plugin not found.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
-        [HttpDelete("{pluginId}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [Obsolete("Please use the UninstallPluginByVersion API.")]
-        public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId)
-        {
-            // If no version is given, return the current instance.
-            var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId)).ToList();
-
-            // Select the un-instanced one first.
-            var plugin = plugins.FirstOrDefault(p => p.Instance is null) ?? plugins.OrderBy(p => p.Manifest.Status).FirstOrDefault();
-
-            if (plugin is not null)
-            {
-                _installationManager.UninstallPlugin(plugin);
-                return NoContent();
-            }
+        return NotFound();
+    }
 
-            return NotFound();
+    /// <summary>
+    /// Gets plugin configuration.
+    /// </summary>
+    /// <param name="pluginId">Plugin id.</param>
+    /// <response code="200">Plugin configuration returned.</response>
+    /// <response code="404">Plugin not found or plugin configuration not found.</response>
+    /// <returns>Plugin configuration.</returns>
+    [HttpGet("{pluginId}/Configuration")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId)
+    {
+        var plugin = _pluginManager.GetPlugin(pluginId);
+        if (plugin?.Instance is IHasPluginConfiguration configPlugin)
+        {
+            return configPlugin.Configuration;
         }
 
-        /// <summary>
-        /// Gets plugin configuration.
-        /// </summary>
-        /// <param name="pluginId">Plugin id.</param>
-        /// <response code="200">Plugin configuration returned.</response>
-        /// <response code="404">Plugin not found or plugin configuration not found.</response>
-        /// <returns>Plugin configuration.</returns>
-        [HttpGet("{pluginId}/Configuration")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId)
-        {
-            var plugin = _pluginManager.GetPlugin(pluginId);
-            if (plugin?.Instance is IHasPluginConfiguration configPlugin)
-            {
-                return configPlugin.Configuration;
-            }
+        return NotFound();
+    }
 
+    /// <summary>
+    /// Updates plugin configuration.
+    /// </summary>
+    /// <remarks>
+    /// Accepts plugin configuration as JSON body.
+    /// </remarks>
+    /// <param name="pluginId">Plugin id.</param>
+    /// <response code="204">Plugin configuration updated.</response>
+    /// <response code="404">Plugin not found or plugin does not have configuration.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
+    [HttpPost("{pluginId}/Configuration")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId)
+    {
+        var plugin = _pluginManager.GetPlugin(pluginId);
+        if (plugin?.Instance is not IHasPluginConfiguration configPlugin)
+        {
             return NotFound();
         }
 
-        /// <summary>
-        /// Updates plugin configuration.
-        /// </summary>
-        /// <remarks>
-        /// Accepts plugin configuration as JSON body.
-        /// </remarks>
-        /// <param name="pluginId">Plugin id.</param>
-        /// <response code="204">Plugin configuration updated.</response>
-        /// <response code="404">Plugin not found or plugin does not have configuration.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
-        [HttpPost("{pluginId}/Configuration")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId)
-        {
-            var plugin = _pluginManager.GetPlugin(pluginId);
-            if (plugin?.Instance is not IHasPluginConfiguration configPlugin)
-            {
-                return NotFound();
-            }
+        var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions)
+            .ConfigureAwait(false);
 
-            var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions)
-                .ConfigureAwait(false);
+        if (configuration is not null)
+        {
+            configPlugin.UpdateConfiguration(configuration);
+        }
 
-            if (configuration is not null)
-            {
-                configPlugin.UpdateConfiguration(configuration);
-            }
+        return NoContent();
+    }
 
-            return NoContent();
+    /// <summary>
+    /// Gets a plugin's image.
+    /// </summary>
+    /// <param name="pluginId">Plugin id.</param>
+    /// <param name="version">Plugin version.</param>
+    /// <response code="200">Plugin image returned.</response>
+    /// <returns>Plugin's image.</returns>
+    [HttpGet("{pluginId}/{version}/Image")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    [AllowAnonymous]
+    public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
+    {
+        var plugin = _pluginManager.GetPlugin(pluginId, version);
+        if (plugin is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets a plugin's image.
-        /// </summary>
-        /// <param name="pluginId">Plugin id.</param>
-        /// <param name="version">Plugin version.</param>
-        /// <response code="200">Plugin image returned.</response>
-        /// <returns>Plugin's image.</returns>
-        [HttpGet("{pluginId}/{version}/Image")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        [AllowAnonymous]
-        public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
+        var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath ?? string.Empty);
+        if (plugin.Manifest.ImagePath is null || !System.IO.File.Exists(imagePath))
         {
-            var plugin = _pluginManager.GetPlugin(pluginId, version);
-            if (plugin is null)
-            {
-                return NotFound();
-            }
-
-            var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath ?? string.Empty);
-            if (plugin.Manifest.ImagePath is null || !System.IO.File.Exists(imagePath))
-            {
-                return NotFound();
-            }
-
-            imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath);
-            return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath));
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets a plugin's manifest.
-        /// </summary>
-        /// <param name="pluginId">Plugin id.</param>
-        /// <response code="204">Plugin manifest returned.</response>
-        /// <response code="404">Plugin not found.</response>
-        /// <returns>A <see cref="PluginManifest"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
-        [HttpPost("{pluginId}/Manifest")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<PluginManifest> GetPluginManifest([FromRoute, Required] Guid pluginId)
-        {
-            var plugin = _pluginManager.GetPlugin(pluginId);
+        imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath);
+        return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath));
+    }
 
-            if (plugin is not null)
-            {
-                return plugin.Manifest;
-            }
+    /// <summary>
+    /// Gets a plugin's manifest.
+    /// </summary>
+    /// <param name="pluginId">Plugin id.</param>
+    /// <response code="204">Plugin manifest returned.</response>
+    /// <response code="404">Plugin not found.</response>
+    /// <returns>A <see cref="PluginManifest"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
+    [HttpPost("{pluginId}/Manifest")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<PluginManifest> GetPluginManifest([FromRoute, Required] Guid pluginId)
+    {
+        var plugin = _pluginManager.GetPlugin(pluginId);
 
-            return NotFound();
+        if (plugin is not null)
+        {
+            return plugin.Manifest;
         }
+
+        return NotFound();
     }
 }

+ 101 - 102
Jellyfin.Api/Controllers/QuickConnectController.cs

@@ -13,126 +13,125 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Quick connect controller.
+/// </summary>
+public class QuickConnectController : BaseJellyfinApiController
 {
+    private readonly IQuickConnect _quickConnect;
+    private readonly IAuthorizationContext _authContext;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="QuickConnectController"/> class.
+    /// </summary>
+    /// <param name="quickConnect">Instance of the <see cref="IQuickConnect"/> interface.</param>
+    /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+    public QuickConnectController(IQuickConnect quickConnect, IAuthorizationContext authContext)
+    {
+        _quickConnect = quickConnect;
+        _authContext = authContext;
+    }
+
     /// <summary>
-    /// Quick connect controller.
+    /// Gets the current quick connect state.
     /// </summary>
-    public class QuickConnectController : BaseJellyfinApiController
+    /// <response code="200">Quick connect state returned.</response>
+    /// <returns>Whether Quick Connect is enabled on the server or not.</returns>
+    [HttpGet("Enabled")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<bool> GetQuickConnectEnabled()
     {
-        private readonly IQuickConnect _quickConnect;
-        private readonly IAuthorizationContext _authContext;
+        return _quickConnect.IsEnabled;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="QuickConnectController"/> class.
-        /// </summary>
-        /// <param name="quickConnect">Instance of the <see cref="IQuickConnect"/> interface.</param>
-        /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
-        public QuickConnectController(IQuickConnect quickConnect, IAuthorizationContext authContext)
+    /// <summary>
+    /// Initiate a new quick connect request.
+    /// </summary>
+    /// <response code="200">Quick connect request successfully created.</response>
+    /// <response code="401">Quick connect is not active on this server.</response>
+    /// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns>
+    [HttpPost("Initiate")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect()
+    {
+        try
         {
-            _quickConnect = quickConnect;
-            _authContext = authContext;
+            var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
+            return _quickConnect.TryConnect(auth);
         }
-
-        /// <summary>
-        /// Gets the current quick connect state.
-        /// </summary>
-        /// <response code="200">Quick connect state returned.</response>
-        /// <returns>Whether Quick Connect is enabled on the server or not.</returns>
-        [HttpGet("Enabled")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<bool> GetQuickConnectEnabled()
+        catch (AuthenticationException)
         {
-            return _quickConnect.IsEnabled;
+            return Unauthorized("Quick connect is disabled");
         }
+    }
+
+    /// <summary>
+    /// Old version of <see cref="InitiateQuickConnect" /> using a GET method.
+    /// Still available to avoid breaking compatibility.
+    /// </summary>
+    /// <returns>The result of <see cref="InitiateQuickConnect" />.</returns>
+    [Obsolete("Use POST request instead")]
+    [HttpGet("Initiate")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public Task<ActionResult<QuickConnectResult>> InitiateQuickConnectLegacy() => InitiateQuickConnect();
 
-        /// <summary>
-        /// Initiate a new quick connect request.
-        /// </summary>
-        /// <response code="200">Quick connect request successfully created.</response>
-        /// <response code="401">Quick connect is not active on this server.</response>
-        /// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns>
-        [HttpPost("Initiate")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect()
+    /// <summary>
+    /// Attempts to retrieve authentication information.
+    /// </summary>
+    /// <param name="secret">Secret previously returned from the Initiate endpoint.</param>
+    /// <response code="200">Quick connect result returned.</response>
+    /// <response code="404">Unknown quick connect secret.</response>
+    /// <returns>An updated <see cref="QuickConnectResult"/>.</returns>
+    [HttpGet("Connect")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<QuickConnectResult> GetQuickConnectState([FromQuery, Required] string secret)
+    {
+        try
+        {
+            return _quickConnect.CheckRequestStatus(secret);
+        }
+        catch (ResourceNotFoundException)
+        {
+            return NotFound("Unknown secret");
+        }
+        catch (AuthenticationException)
         {
-            try
-            {
-                var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
-                return _quickConnect.TryConnect(auth);
-            }
-            catch (AuthenticationException)
-            {
-                return Unauthorized("Quick connect is disabled");
-            }
+            return Unauthorized("Quick connect is disabled");
         }
+    }
 
-        /// <summary>
-        /// Old version of <see cref="InitiateQuickConnect" /> using a GET method.
-        /// Still available to avoid breaking compatibility.
-        /// </summary>
-        /// <returns>The result of <see cref="InitiateQuickConnect" />.</returns>
-        [Obsolete("Use POST request instead")]
-        [HttpGet("Initiate")]
-        [ApiExplorerSettings(IgnoreApi = true)]
-        public Task<ActionResult<QuickConnectResult>> InitiateQuickConnectLegacy() => InitiateQuickConnect();
+    /// <summary>
+    /// Authorizes a pending quick connect request.
+    /// </summary>
+    /// <param name="code">Quick connect code to authorize.</param>
+    /// <param name="userId">The user the authorize. Access to the requested user is required.</param>
+    /// <response code="200">Quick connect result authorized successfully.</response>
+    /// <response code="403">Unknown user id.</response>
+    /// <returns>Boolean indicating if the authorization was successful.</returns>
+    [HttpPost("Authorize")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public async Task<ActionResult<bool>> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null)
+    {
+        var currentUserId = User.GetUserId();
+        var actualUserId = userId ?? currentUserId;
 
-        /// <summary>
-        /// Attempts to retrieve authentication information.
-        /// </summary>
-        /// <param name="secret">Secret previously returned from the Initiate endpoint.</param>
-        /// <response code="200">Quick connect result returned.</response>
-        /// <response code="404">Unknown quick connect secret.</response>
-        /// <returns>An updated <see cref="QuickConnectResult"/>.</returns>
-        [HttpGet("Connect")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<QuickConnectResult> GetQuickConnectState([FromQuery, Required] string secret)
+        if (actualUserId.Equals(default) || (!userId.Equals(currentUserId) && !User.IsInRole(UserRoles.Administrator)))
         {
-            try
-            {
-                return _quickConnect.CheckRequestStatus(secret);
-            }
-            catch (ResourceNotFoundException)
-            {
-                return NotFound("Unknown secret");
-            }
-            catch (AuthenticationException)
-            {
-                return Unauthorized("Quick connect is disabled");
-            }
+            return Forbid("Unknown user id");
         }
 
-        /// <summary>
-        /// Authorizes a pending quick connect request.
-        /// </summary>
-        /// <param name="code">Quick connect code to authorize.</param>
-        /// <param name="userId">The user the authorize. Access to the requested user is required.</param>
-        /// <response code="200">Quick connect result authorized successfully.</response>
-        /// <response code="403">Unknown user id.</response>
-        /// <returns>Boolean indicating if the authorization was successful.</returns>
-        [HttpPost("Authorize")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        public async Task<ActionResult<bool>> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null)
+        try
         {
-            var currentUserId = User.GetUserId();
-            var actualUserId = userId ?? currentUserId;
-
-            if (actualUserId.Equals(default) || (!userId.Equals(currentUserId) && !User.IsInRole(UserRoles.Administrator)))
-            {
-                return Forbid("Unknown user id");
-            }
-
-            try
-            {
-                return await _quickConnect.AuthorizeRequest(actualUserId, code).ConfigureAwait(false);
-            }
-            catch (AuthenticationException)
-            {
-                return Unauthorized("Quick connect is disabled");
-            }
+            return await _quickConnect.AuthorizeRequest(actualUserId, code).ConfigureAwait(false);
+        }
+        catch (AuthenticationException)
+        {
+            return Unauthorized("Quick connect is disabled");
         }
     }
 }

+ 138 - 139
Jellyfin.Api/Controllers/RemoteImageController.cs

@@ -15,165 +15,164 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Remote Images Controller.
+/// </summary>
+[Route("")]
+public class RemoteImageController : BaseJellyfinApiController
 {
+    private readonly IProviderManager _providerManager;
+    private readonly IServerApplicationPaths _applicationPaths;
+    private readonly ILibraryManager _libraryManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="RemoteImageController"/> class.
+    /// </summary>
+    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+    /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    public RemoteImageController(
+        IProviderManager providerManager,
+        IServerApplicationPaths applicationPaths,
+        ILibraryManager libraryManager)
+    {
+        _providerManager = providerManager;
+        _applicationPaths = applicationPaths;
+        _libraryManager = libraryManager;
+    }
+
     /// <summary>
-    /// Remote Images Controller.
+    /// Gets available remote images for an item.
     /// </summary>
-    [Route("")]
-    public class RemoteImageController : BaseJellyfinApiController
+    /// <param name="itemId">Item Id.</param>
+    /// <param name="type">The image type.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="providerName">Optional. The image provider to use.</param>
+    /// <param name="includeAllLanguages">Optional. Include all languages.</param>
+    /// <response code="200">Remote Images returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>Remote Image Result.</returns>
+    [HttpGet("Items/{itemId}/RemoteImages")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<RemoteImageResult>> GetRemoteImages(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] ImageType? type,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] string? providerName,
+        [FromQuery] bool includeAllLanguages = false)
     {
-        private readonly IProviderManager _providerManager;
-        private readonly IServerApplicationPaths _applicationPaths;
-        private readonly ILibraryManager _libraryManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="RemoteImageController"/> class.
-        /// </summary>
-        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
-        /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        public RemoteImageController(
-            IProviderManager providerManager,
-            IServerApplicationPaths applicationPaths,
-            ILibraryManager libraryManager)
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
         {
-            _providerManager = providerManager;
-            _applicationPaths = applicationPaths;
-            _libraryManager = libraryManager;
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets available remote images for an item.
-        /// </summary>
-        /// <param name="itemId">Item Id.</param>
-        /// <param name="type">The image type.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="providerName">Optional. The image provider to use.</param>
-        /// <param name="includeAllLanguages">Optional. Include all languages.</param>
-        /// <response code="200">Remote Images returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>Remote Image Result.</returns>
-        [HttpGet("Items/{itemId}/RemoteImages")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult<RemoteImageResult>> GetRemoteImages(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] ImageType? type,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] string? providerName,
-            [FromQuery] bool includeAllLanguages = false)
+        var images = await _providerManager.GetAvailableRemoteImages(
+                item,
+                new RemoteImageQuery(providerName ?? string.Empty)
+                {
+                    IncludeAllLanguages = includeAllLanguages,
+                    IncludeDisabledProviders = true,
+                    ImageType = type
+                },
+                CancellationToken.None)
+            .ConfigureAwait(false);
+
+        var imageArray = images.ToArray();
+        var allProviders = _providerManager.GetRemoteImageProviderInfo(item);
+        if (type.HasValue)
         {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
-
-            var images = await _providerManager.GetAvailableRemoteImages(
-                    item,
-                    new RemoteImageQuery(providerName ?? string.Empty)
-                    {
-                        IncludeAllLanguages = includeAllLanguages,
-                        IncludeDisabledProviders = true,
-                        ImageType = type
-                    },
-                    CancellationToken.None)
-                .ConfigureAwait(false);
-
-            var imageArray = images.ToArray();
-            var allProviders = _providerManager.GetRemoteImageProviderInfo(item);
-            if (type.HasValue)
-            {
-                allProviders = allProviders.Where(o => o.SupportedImages.Contains(type.Value));
-            }
-
-            var result = new RemoteImageResult
-            {
-                TotalRecordCount = imageArray.Length,
-                Providers = allProviders.Select(o => o.Name)
-                    .Distinct(StringComparer.OrdinalIgnoreCase)
-                    .ToArray()
-            };
-
-            if (startIndex.HasValue)
-            {
-                imageArray = imageArray.Skip(startIndex.Value).ToArray();
-            }
-
-            if (limit.HasValue)
-            {
-                imageArray = imageArray.Take(limit.Value).ToArray();
-            }
-
-            result.Images = imageArray;
-            return result;
+            allProviders = allProviders.Where(o => o.SupportedImages.Contains(type.Value));
         }
 
-        /// <summary>
-        /// Gets available remote image providers for an item.
-        /// </summary>
-        /// <param name="itemId">Item Id.</param>
-        /// <response code="200">Returned remote image providers.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>List of remote image providers.</returns>
-        [HttpGet("Items/{itemId}/RemoteImages/Providers")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute, Required] Guid itemId)
+        var result = new RemoteImageResult
         {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+            TotalRecordCount = imageArray.Length,
+            Providers = allProviders.Select(o => o.Name)
+                .Distinct(StringComparer.OrdinalIgnoreCase)
+                .ToArray()
+        };
 
-            return Ok(_providerManager.GetRemoteImageProviderInfo(item));
+        if (startIndex.HasValue)
+        {
+            imageArray = imageArray.Skip(startIndex.Value).ToArray();
         }
 
-        /// <summary>
-        /// Downloads a remote image for an item.
-        /// </summary>
-        /// <param name="itemId">Item Id.</param>
-        /// <param name="type">The image type.</param>
-        /// <param name="imageUrl">The image url.</param>
-        /// <response code="204">Remote image downloaded.</response>
-        /// <response code="404">Remote image not found.</response>
-        /// <returns>Download status.</returns>
-        [HttpPost("Items/{itemId}/RemoteImages/Download")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> DownloadRemoteImage(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery, Required] ImageType type,
-            [FromQuery] string? imageUrl)
+        if (limit.HasValue)
         {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+            imageArray = imageArray.Take(limit.Value).ToArray();
+        }
 
-            await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None)
-                .ConfigureAwait(false);
+        result.Images = imageArray;
+        return result;
+    }
 
-            await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
-            return NoContent();
+    /// <summary>
+    /// Gets available remote image providers for an item.
+    /// </summary>
+    /// <param name="itemId">Item Id.</param>
+    /// <response code="200">Returned remote image providers.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>List of remote image providers.</returns>
+    [HttpGet("Items/{itemId}/RemoteImages/Providers")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute, Required] Guid itemId)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets the full cache path.
-        /// </summary>
-        /// <param name="filename">The filename.</param>
-        /// <returns>System.String.</returns>
-        private string GetFullCachePath(string filename)
+        return Ok(_providerManager.GetRemoteImageProviderInfo(item));
+    }
+
+    /// <summary>
+    /// Downloads a remote image for an item.
+    /// </summary>
+    /// <param name="itemId">Item Id.</param>
+    /// <param name="type">The image type.</param>
+    /// <param name="imageUrl">The image url.</param>
+    /// <response code="204">Remote image downloaded.</response>
+    /// <response code="404">Remote image not found.</response>
+    /// <returns>Download status.</returns>
+    [HttpPost("Items/{itemId}/RemoteImages/Download")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> DownloadRemoteImage(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery, Required] ImageType type,
+        [FromQuery] string? imageUrl)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
         {
-            return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename);
+            return NotFound();
         }
+
+        await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None)
+            .ConfigureAwait(false);
+
+        await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Gets the full cache path.
+    /// </summary>
+    /// <param name="filename">The filename.</param>
+    /// <returns>System.String.</returns>
+    private string GetFullCachePath(string filename)
+    {
+        return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename);
     }
 }

+ 121 - 122
Jellyfin.Api/Controllers/ScheduledTasksController.cs

@@ -8,154 +8,153 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Scheduled Tasks Controller.
+/// </summary>
+[Authorize(Policy = Policies.RequiresElevation)]
+public class ScheduledTasksController : BaseJellyfinApiController
 {
+    private readonly ITaskManager _taskManager;
+
     /// <summary>
-    /// Scheduled Tasks Controller.
+    /// Initializes a new instance of the <see cref="ScheduledTasksController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.RequiresElevation)]
-    public class ScheduledTasksController : BaseJellyfinApiController
+    /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param>
+    public ScheduledTasksController(ITaskManager taskManager)
     {
-        private readonly ITaskManager _taskManager;
+        _taskManager = taskManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ScheduledTasksController"/> class.
-        /// </summary>
-        /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param>
-        public ScheduledTasksController(ITaskManager taskManager)
-        {
-            _taskManager = taskManager;
-        }
+    /// <summary>
+    /// Get tasks.
+    /// </summary>
+    /// <param name="isHidden">Optional filter tasks that are hidden, or not.</param>
+    /// <param name="isEnabled">Optional filter tasks that are enabled, or not.</param>
+    /// <response code="200">Scheduled tasks retrieved.</response>
+    /// <returns>The list of scheduled tasks.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public IEnumerable<TaskInfo> GetTasks(
+        [FromQuery] bool? isHidden,
+        [FromQuery] bool? isEnabled)
+    {
+        IEnumerable<IScheduledTaskWorker> tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name);
 
-        /// <summary>
-        /// Get tasks.
-        /// </summary>
-        /// <param name="isHidden">Optional filter tasks that are hidden, or not.</param>
-        /// <param name="isEnabled">Optional filter tasks that are enabled, or not.</param>
-        /// <response code="200">Scheduled tasks retrieved.</response>
-        /// <returns>The list of scheduled tasks.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public IEnumerable<TaskInfo> GetTasks(
-            [FromQuery] bool? isHidden,
-            [FromQuery] bool? isEnabled)
+        foreach (var task in tasks)
         {
-            IEnumerable<IScheduledTaskWorker> tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name);
-
-            foreach (var task in tasks)
+            if (task.ScheduledTask is IConfigurableScheduledTask scheduledTask)
             {
-                if (task.ScheduledTask is IConfigurableScheduledTask scheduledTask)
+                if (isHidden.HasValue && isHidden.Value != scheduledTask.IsHidden)
                 {
-                    if (isHidden.HasValue && isHidden.Value != scheduledTask.IsHidden)
-                    {
-                        continue;
-                    }
-
-                    if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled)
-                    {
-                        continue;
-                    }
+                    continue;
                 }
 
-                yield return ScheduledTaskHelpers.GetTaskInfo(task);
+                if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled)
+                {
+                    continue;
+                }
             }
-        }
 
-        /// <summary>
-        /// Get task by id.
-        /// </summary>
-        /// <param name="taskId">Task Id.</param>
-        /// <response code="200">Task retrieved.</response>
-        /// <response code="404">Task not found.</response>
-        /// <returns>An <see cref="OkResult"/> containing the task on success, or a <see cref="NotFoundResult"/> if the task could not be found.</returns>
-        [HttpGet("{taskId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<TaskInfo> GetTask([FromRoute, Required] string taskId)
-        {
-            var task = _taskManager.ScheduledTasks.FirstOrDefault(i =>
-                string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase));
+            yield return ScheduledTaskHelpers.GetTaskInfo(task);
+        }
+    }
 
-            if (task is null)
-            {
-                return NotFound();
-            }
+    /// <summary>
+    /// Get task by id.
+    /// </summary>
+    /// <param name="taskId">Task Id.</param>
+    /// <response code="200">Task retrieved.</response>
+    /// <response code="404">Task not found.</response>
+    /// <returns>An <see cref="OkResult"/> containing the task on success, or a <see cref="NotFoundResult"/> if the task could not be found.</returns>
+    [HttpGet("{taskId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<TaskInfo> GetTask([FromRoute, Required] string taskId)
+    {
+        var task = _taskManager.ScheduledTasks.FirstOrDefault(i =>
+            string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase));
 
-            return ScheduledTaskHelpers.GetTaskInfo(task);
+        if (task is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Start specified task.
-        /// </summary>
-        /// <param name="taskId">Task Id.</param>
-        /// <response code="204">Task started.</response>
-        /// <response code="404">Task not found.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
-        [HttpPost("Running/{taskId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult StartTask([FromRoute, Required] string taskId)
-        {
-            var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
-                o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+        return ScheduledTaskHelpers.GetTaskInfo(task);
+    }
 
-            if (task is null)
-            {
-                return NotFound();
-            }
+    /// <summary>
+    /// Start specified task.
+    /// </summary>
+    /// <param name="taskId">Task Id.</param>
+    /// <response code="204">Task started.</response>
+    /// <response code="404">Task not found.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+    [HttpPost("Running/{taskId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult StartTask([FromRoute, Required] string taskId)
+    {
+        var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+            o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
 
-            _taskManager.Execute(task, new TaskOptions());
-            return NoContent();
+        if (task is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Stop specified task.
-        /// </summary>
-        /// <param name="taskId">Task Id.</param>
-        /// <response code="204">Task stopped.</response>
-        /// <response code="404">Task not found.</response>
-        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
-        [HttpDelete("Running/{taskId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult StopTask([FromRoute, Required] string taskId)
-        {
-            var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
-                o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+        _taskManager.Execute(task, new TaskOptions());
+        return NoContent();
+    }
 
-            if (task is null)
-            {
-                return NotFound();
-            }
+    /// <summary>
+    /// Stop specified task.
+    /// </summary>
+    /// <param name="taskId">Task Id.</param>
+    /// <response code="204">Task stopped.</response>
+    /// <response code="404">Task not found.</response>
+    /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+    [HttpDelete("Running/{taskId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult StopTask([FromRoute, Required] string taskId)
+    {
+        var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+            o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
 
-            _taskManager.Cancel(task);
-            return NoContent();
+        if (task is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Update specified task triggers.
-        /// </summary>
-        /// <param name="taskId">Task Id.</param>
-        /// <param name="triggerInfos">Triggers.</param>
-        /// <response code="204">Task triggers updated.</response>
-        /// <response code="404">Task not found.</response>
-        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
-        [HttpPost("{taskId}/Triggers")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult UpdateTask(
-            [FromRoute, Required] string taskId,
-            [FromBody, Required] TaskTriggerInfo[] triggerInfos)
-        {
-            var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
-                o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
-            if (task is null)
-            {
-                return NotFound();
-            }
+        _taskManager.Cancel(task);
+        return NoContent();
+    }
 
-            task.Triggers = triggerInfos;
-            return NoContent();
+    /// <summary>
+    /// Update specified task triggers.
+    /// </summary>
+    /// <param name="taskId">Task Id.</param>
+    /// <param name="triggerInfos">Triggers.</param>
+    /// <response code="204">Task triggers updated.</response>
+    /// <response code="404">Task not found.</response>
+    /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+    [HttpPost("{taskId}/Triggers")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult UpdateTask(
+        [FromRoute, Required] string taskId,
+        [FromBody, Required] TaskTriggerInfo[] triggerInfos)
+    {
+        var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+            o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+        if (task is null)
+        {
+            return NotFound();
         }
+
+        task.Triggers = triggerInfos;
+        return NoContent();
     }
 }

+ 201 - 202
Jellyfin.Api/Controllers/SearchController.cs

@@ -20,247 +20,246 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Search controller.
+/// </summary>
+[Route("Search/Hints")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class SearchController : BaseJellyfinApiController
 {
+    private readonly ISearchEngine _searchEngine;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IDtoService _dtoService;
+    private readonly IImageProcessor _imageProcessor;
+
     /// <summary>
-    /// Search controller.
+    /// Initializes a new instance of the <see cref="SearchController"/> class.
     /// </summary>
-    [Route("Search/Hints")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class SearchController : BaseJellyfinApiController
+    /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param>
+    /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
+    /// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
+    public SearchController(
+        ISearchEngine searchEngine,
+        ILibraryManager libraryManager,
+        IDtoService dtoService,
+        IImageProcessor imageProcessor)
     {
-        private readonly ISearchEngine _searchEngine;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-        private readonly IImageProcessor _imageProcessor;
+        _searchEngine = searchEngine;
+        _libraryManager = libraryManager;
+        _dtoService = dtoService;
+        _imageProcessor = imageProcessor;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SearchController"/> class.
-        /// </summary>
-        /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param>
-        /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
-        /// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
-        public SearchController(
-            ISearchEngine searchEngine,
-            ILibraryManager libraryManager,
-            IDtoService dtoService,
-            IImageProcessor imageProcessor)
+    /// <summary>
+    /// Gets the search hint result.
+    /// </summary>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="userId">Optional. Supply a user id to search within a user's library or omit to search all.</param>
+    /// <param name="searchTerm">The search term to filter on.</param>
+    /// <param name="includeItemTypes">If specified, only results with the specified item types are returned. This allows multiple, comma delimited.</param>
+    /// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimited.</param>
+    /// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimited.</param>
+    /// <param name="parentId">If specified, only children of the parent are returned.</param>
+    /// <param name="isMovie">Optional filter for movies.</param>
+    /// <param name="isSeries">Optional filter for series.</param>
+    /// <param name="isNews">Optional filter for news.</param>
+    /// <param name="isKids">Optional filter for kids.</param>
+    /// <param name="isSports">Optional filter for sports.</param>
+    /// <param name="includePeople">Optional filter whether to include people.</param>
+    /// <param name="includeMedia">Optional filter whether to include media.</param>
+    /// <param name="includeGenres">Optional filter whether to include genres.</param>
+    /// <param name="includeStudios">Optional filter whether to include studios.</param>
+    /// <param name="includeArtists">Optional filter whether to include artists.</param>
+    /// <response code="200">Search hint returned.</response>
+    /// <returns>An <see cref="SearchHintResult"/> with the results of the search.</returns>
+    [HttpGet]
+    [Description("Gets search hints based on a search term")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<SearchHintResult> GetSearchHints(
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] Guid? userId,
+        [FromQuery, Required] string searchTerm,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+        [FromQuery] Guid? parentId,
+        [FromQuery] bool? isMovie,
+        [FromQuery] bool? isSeries,
+        [FromQuery] bool? isNews,
+        [FromQuery] bool? isKids,
+        [FromQuery] bool? isSports,
+        [FromQuery] bool includePeople = true,
+        [FromQuery] bool includeMedia = true,
+        [FromQuery] bool includeGenres = true,
+        [FromQuery] bool includeStudios = true,
+        [FromQuery] bool includeArtists = true)
+    {
+        var result = _searchEngine.GetSearchHints(new SearchQuery
         {
-            _searchEngine = searchEngine;
-            _libraryManager = libraryManager;
-            _dtoService = dtoService;
-            _imageProcessor = imageProcessor;
-        }
+            Limit = limit,
+            SearchTerm = searchTerm,
+            IncludeArtists = includeArtists,
+            IncludeGenres = includeGenres,
+            IncludeMedia = includeMedia,
+            IncludePeople = includePeople,
+            IncludeStudios = includeStudios,
+            StartIndex = startIndex,
+            UserId = userId ?? Guid.Empty,
+            IncludeItemTypes = includeItemTypes,
+            ExcludeItemTypes = excludeItemTypes,
+            MediaTypes = mediaTypes,
+            ParentId = parentId,
 
-        /// <summary>
-        /// Gets the search hint result.
-        /// </summary>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="userId">Optional. Supply a user id to search within a user's library or omit to search all.</param>
-        /// <param name="searchTerm">The search term to filter on.</param>
-        /// <param name="includeItemTypes">If specified, only results with the specified item types are returned. This allows multiple, comma delimited.</param>
-        /// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimited.</param>
-        /// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimited.</param>
-        /// <param name="parentId">If specified, only children of the parent are returned.</param>
-        /// <param name="isMovie">Optional filter for movies.</param>
-        /// <param name="isSeries">Optional filter for series.</param>
-        /// <param name="isNews">Optional filter for news.</param>
-        /// <param name="isKids">Optional filter for kids.</param>
-        /// <param name="isSports">Optional filter for sports.</param>
-        /// <param name="includePeople">Optional filter whether to include people.</param>
-        /// <param name="includeMedia">Optional filter whether to include media.</param>
-        /// <param name="includeGenres">Optional filter whether to include genres.</param>
-        /// <param name="includeStudios">Optional filter whether to include studios.</param>
-        /// <param name="includeArtists">Optional filter whether to include artists.</param>
-        /// <response code="200">Search hint returned.</response>
-        /// <returns>An <see cref="SearchHintResult"/> with the results of the search.</returns>
-        [HttpGet]
-        [Description("Gets search hints based on a search term")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<SearchHintResult> GetSearchHints(
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] Guid? userId,
-            [FromQuery, Required] string searchTerm,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
-            [FromQuery] Guid? parentId,
-            [FromQuery] bool? isMovie,
-            [FromQuery] bool? isSeries,
-            [FromQuery] bool? isNews,
-            [FromQuery] bool? isKids,
-            [FromQuery] bool? isSports,
-            [FromQuery] bool includePeople = true,
-            [FromQuery] bool includeMedia = true,
-            [FromQuery] bool includeGenres = true,
-            [FromQuery] bool includeStudios = true,
-            [FromQuery] bool includeArtists = true)
-        {
-            var result = _searchEngine.GetSearchHints(new SearchQuery
-            {
-                Limit = limit,
-                SearchTerm = searchTerm,
-                IncludeArtists = includeArtists,
-                IncludeGenres = includeGenres,
-                IncludeMedia = includeMedia,
-                IncludePeople = includePeople,
-                IncludeStudios = includeStudios,
-                StartIndex = startIndex,
-                UserId = userId ?? Guid.Empty,
-                IncludeItemTypes = includeItemTypes,
-                ExcludeItemTypes = excludeItemTypes,
-                MediaTypes = mediaTypes,
-                ParentId = parentId,
+            IsKids = isKids,
+            IsMovie = isMovie,
+            IsNews = isNews,
+            IsSeries = isSeries,
+            IsSports = isSports
+        });
 
-                IsKids = isKids,
-                IsMovie = isMovie,
-                IsNews = isNews,
-                IsSeries = isSeries,
-                IsSports = isSports
-            });
+        return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount);
+    }
 
-            return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount);
-        }
+    /// <summary>
+    /// Gets the search hint result.
+    /// </summary>
+    /// <param name="hintInfo">The hint info.</param>
+    /// <returns>SearchHintResult.</returns>
+    private SearchHint GetSearchHintResult(SearchHintInfo hintInfo)
+    {
+        var item = hintInfo.Item;
 
-        /// <summary>
-        /// Gets the search hint result.
-        /// </summary>
-        /// <param name="hintInfo">The hint info.</param>
-        /// <returns>SearchHintResult.</returns>
-        private SearchHint GetSearchHintResult(SearchHintInfo hintInfo)
+        var result = new SearchHint
         {
-            var item = hintInfo.Item;
-
-            var result = new SearchHint
-            {
-                Name = item.Name,
-                IndexNumber = item.IndexNumber,
-                ParentIndexNumber = item.ParentIndexNumber,
-                Id = item.Id,
-                Type = item.GetBaseItemKind(),
-                MediaType = item.MediaType,
-                MatchedTerm = hintInfo.MatchedTerm,
-                RunTimeTicks = item.RunTimeTicks,
-                ProductionYear = item.ProductionYear,
-                ChannelId = item.ChannelId,
-                EndDate = item.EndDate
-            };
+            Name = item.Name,
+            IndexNumber = item.IndexNumber,
+            ParentIndexNumber = item.ParentIndexNumber,
+            Id = item.Id,
+            Type = item.GetBaseItemKind(),
+            MediaType = item.MediaType,
+            MatchedTerm = hintInfo.MatchedTerm,
+            RunTimeTicks = item.RunTimeTicks,
+            ProductionYear = item.ProductionYear,
+            ChannelId = item.ChannelId,
+            EndDate = item.EndDate
+        };
 
 #pragma warning disable CS0618
-            // Kept for compatibility with older clients
-            result.ItemId = result.Id;
+        // Kept for compatibility with older clients
+        result.ItemId = result.Id;
 #pragma warning restore CS0618
 
-            if (item.IsFolder)
-            {
-                result.IsFolder = true;
-            }
-
-            var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary);
+        if (item.IsFolder)
+        {
+            result.IsFolder = true;
+        }
 
-            if (primaryImageTag is not null)
-            {
-                result.PrimaryImageTag = primaryImageTag;
-                result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item);
-            }
+        var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary);
 
-            SetThumbImageInfo(result, item);
-            SetBackdropImageInfo(result, item);
+        if (primaryImageTag is not null)
+        {
+            result.PrimaryImageTag = primaryImageTag;
+            result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item);
+        }
 
-            switch (item)
-            {
-                case IHasSeries hasSeries:
-                    result.Series = hasSeries.SeriesName;
-                    break;
-                case LiveTvProgram program:
-                    result.StartDate = program.StartDate;
-                    break;
-                case Series series:
-                    if (series.Status.HasValue)
-                    {
-                        result.Status = series.Status.Value.ToString();
-                    }
+        SetThumbImageInfo(result, item);
+        SetBackdropImageInfo(result, item);
 
-                    break;
-                case MusicAlbum album:
-                    result.Artists = album.Artists;
-                    result.AlbumArtist = album.AlbumArtist;
-                    break;
-                case Audio song:
-                    result.AlbumArtist = song.AlbumArtists?.FirstOrDefault();
-                    result.Artists = song.Artists;
+        switch (item)
+        {
+            case IHasSeries hasSeries:
+                result.Series = hasSeries.SeriesName;
+                break;
+            case LiveTvProgram program:
+                result.StartDate = program.StartDate;
+                break;
+            case Series series:
+                if (series.Status.HasValue)
+                {
+                    result.Status = series.Status.Value.ToString();
+                }
 
-                    MusicAlbum musicAlbum = song.AlbumEntity;
+                break;
+            case MusicAlbum album:
+                result.Artists = album.Artists;
+                result.AlbumArtist = album.AlbumArtist;
+                break;
+            case Audio song:
+                result.AlbumArtist = song.AlbumArtists?.FirstOrDefault();
+                result.Artists = song.Artists;
 
-                    if (musicAlbum is not null)
-                    {
-                        result.Album = musicAlbum.Name;
-                        result.AlbumId = musicAlbum.Id;
-                    }
-                    else
-                    {
-                        result.Album = song.Album;
-                    }
+                MusicAlbum musicAlbum = song.AlbumEntity;
 
-                    break;
-            }
+                if (musicAlbum is not null)
+                {
+                    result.Album = musicAlbum.Name;
+                    result.AlbumId = musicAlbum.Id;
+                }
+                else
+                {
+                    result.Album = song.Album;
+                }
 
-            if (!item.ChannelId.Equals(default))
-            {
-                var channel = _libraryManager.GetItemById(item.ChannelId);
-                result.ChannelName = channel?.Name;
-            }
+                break;
+        }
 
-            return result;
+        if (!item.ChannelId.Equals(default))
+        {
+            var channel = _libraryManager.GetItemById(item.ChannelId);
+            result.ChannelName = channel?.Name;
         }
 
-        private void SetThumbImageInfo(SearchHint hint, BaseItem item)
+        return result;
+    }
+
+    private void SetThumbImageInfo(SearchHint hint, BaseItem item)
+    {
+        var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null;
+
+        if (itemWithImage is null && item is Episode)
         {
-            var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null;
+            itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb);
+        }
 
-            if (itemWithImage is null && item is Episode)
-            {
-                itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb);
-            }
+        itemWithImage ??= GetParentWithImage<BaseItem>(item, ImageType.Thumb);
 
-            itemWithImage ??= GetParentWithImage<BaseItem>(item, ImageType.Thumb);
+        if (itemWithImage is not null)
+        {
+            var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb);
 
-            if (itemWithImage is not null)
+            if (tag is not null)
             {
-                var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb);
-
-                if (tag is not null)
-                {
-                    hint.ThumbImageTag = tag;
-                    hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture);
-                }
+                hint.ThumbImageTag = tag;
+                hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture);
             }
         }
+    }
+
+    private void SetBackdropImageInfo(SearchHint hint, BaseItem item)
+    {
+        var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null)
+            ?? GetParentWithImage<BaseItem>(item, ImageType.Backdrop);
 
-        private void SetBackdropImageInfo(SearchHint hint, BaseItem item)
+        if (itemWithImage is not null)
         {
-            var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null)
-                ?? GetParentWithImage<BaseItem>(item, ImageType.Backdrop);
+            var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop);
 
-            if (itemWithImage is not null)
+            if (tag is not null)
             {
-                var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop);
-
-                if (tag is not null)
-                {
-                    hint.BackdropImageTag = tag;
-                    hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture);
-                }
+                hint.BackdropImageTag = tag;
+                hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture);
             }
         }
+    }
 
-        private T? GetParentWithImage<T>(BaseItem item, ImageType type)
-            where T : BaseItem
-        {
-            return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type));
-        }
+    private T? GetParentWithImage<T>(BaseItem item, ImageType type)
+        where T : BaseItem
+    {
+        return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type));
     }
 }

+ 415 - 416
Jellyfin.Api/Controllers/SessionController.cs

@@ -19,480 +19,479 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The session controller.
+/// </summary>
+[Route("")]
+public class SessionController : BaseJellyfinApiController
 {
+    private readonly ISessionManager _sessionManager;
+    private readonly IUserManager _userManager;
+    private readonly IDeviceManager _deviceManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SessionController"/> class.
+    /// </summary>
+    /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
+    /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param>
+    /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
+    public SessionController(
+        ISessionManager sessionManager,
+        IUserManager userManager,
+        IDeviceManager deviceManager)
+    {
+        _sessionManager = sessionManager;
+        _userManager = userManager;
+        _deviceManager = deviceManager;
+    }
+
     /// <summary>
-    /// The session controller.
+    /// Gets a list of sessions.
     /// </summary>
-    [Route("")]
-    public class SessionController : BaseJellyfinApiController
+    /// <param name="controllableByUserId">Filter by sessions that a given user is allowed to remote control.</param>
+    /// <param name="deviceId">Filter by device Id.</param>
+    /// <param name="activeWithinSeconds">Optional. Filter by sessions that were active in the last n seconds.</param>
+    /// <response code="200">List of sessions returned.</response>
+    /// <returns>An <see cref="IEnumerable{SessionInfo}"/> with the available sessions.</returns>
+    [HttpGet("Sessions")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<SessionInfo>> GetSessions(
+        [FromQuery] Guid? controllableByUserId,
+        [FromQuery] string? deviceId,
+        [FromQuery] int? activeWithinSeconds)
     {
-        private readonly ISessionManager _sessionManager;
-        private readonly IUserManager _userManager;
-        private readonly IDeviceManager _deviceManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SessionController"/> class.
-        /// </summary>
-        /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
-        /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param>
-        /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
-        public SessionController(
-            ISessionManager sessionManager,
-            IUserManager userManager,
-            IDeviceManager deviceManager)
+        var result = _sessionManager.Sessions;
+
+        if (!string.IsNullOrEmpty(deviceId))
         {
-            _sessionManager = sessionManager;
-            _userManager = userManager;
-            _deviceManager = deviceManager;
+            result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase));
         }
 
-        /// <summary>
-        /// Gets a list of sessions.
-        /// </summary>
-        /// <param name="controllableByUserId">Filter by sessions that a given user is allowed to remote control.</param>
-        /// <param name="deviceId">Filter by device Id.</param>
-        /// <param name="activeWithinSeconds">Optional. Filter by sessions that were active in the last n seconds.</param>
-        /// <response code="200">List of sessions returned.</response>
-        /// <returns>An <see cref="IEnumerable{SessionInfo}"/> with the available sessions.</returns>
-        [HttpGet("Sessions")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<SessionInfo>> GetSessions(
-            [FromQuery] Guid? controllableByUserId,
-            [FromQuery] string? deviceId,
-            [FromQuery] int? activeWithinSeconds)
+        if (controllableByUserId.HasValue && !controllableByUserId.Equals(default))
         {
-            var result = _sessionManager.Sessions;
+            result = result.Where(i => i.SupportsRemoteControl);
 
-            if (!string.IsNullOrEmpty(deviceId))
+            var user = _userManager.GetUserById(controllableByUserId.Value);
+
+            if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
             {
-                result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase));
+                result = result.Where(i => i.UserId.Equals(default) || i.ContainsUser(controllableByUserId.Value));
             }
 
-            if (controllableByUserId.HasValue && !controllableByUserId.Equals(default))
+            if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl))
             {
-                result = result.Where(i => i.SupportsRemoteControl);
-
-                var user = _userManager.GetUserById(controllableByUserId.Value);
-
-                if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
-                {
-                    result = result.Where(i => i.UserId.Equals(default) || i.ContainsUser(controllableByUserId.Value));
-                }
-
-                if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl))
-                {
-                    result = result.Where(i => !i.UserId.Equals(default));
-                }
+                result = result.Where(i => !i.UserId.Equals(default));
+            }
 
-                if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0)
-                {
-                    var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value);
-                    result = result.Where(i => i.LastActivityDate >= minActiveDate);
-                }
+            if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0)
+            {
+                var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value);
+                result = result.Where(i => i.LastActivityDate >= minActiveDate);
+            }
 
-                result = result.Where(i =>
+            result = result.Where(i =>
+            {
+                if (!string.IsNullOrWhiteSpace(i.DeviceId))
                 {
-                    if (!string.IsNullOrWhiteSpace(i.DeviceId))
+                    if (!_deviceManager.CanAccessDevice(user, i.DeviceId))
                     {
-                        if (!_deviceManager.CanAccessDevice(user, i.DeviceId))
-                        {
-                            return false;
-                        }
+                        return false;
                     }
+                }
 
-                    return true;
-                });
-            }
-
-            return Ok(result);
+                return true;
+            });
         }
 
-        /// <summary>
-        /// Instructs a session to browse to an item or view.
-        /// </summary>
-        /// <param name="sessionId">The session Id.</param>
-        /// <param name="itemType">The type of item to browse to.</param>
-        /// <param name="itemId">The Id of the item.</param>
-        /// <param name="itemName">The name of the item.</param>
-        /// <response code="204">Instruction sent to session.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/{sessionId}/Viewing")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> DisplayContent(
-            [FromRoute, Required] string sessionId,
-            [FromQuery, Required] BaseItemKind itemType,
-            [FromQuery, Required] string itemId,
-            [FromQuery, Required] string itemName)
+        return Ok(result);
+    }
+
+    /// <summary>
+    /// Instructs a session to browse to an item or view.
+    /// </summary>
+    /// <param name="sessionId">The session Id.</param>
+    /// <param name="itemType">The type of item to browse to.</param>
+    /// <param name="itemId">The Id of the item.</param>
+    /// <param name="itemName">The name of the item.</param>
+    /// <response code="204">Instruction sent to session.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/{sessionId}/Viewing")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> DisplayContent(
+        [FromRoute, Required] string sessionId,
+        [FromQuery, Required] BaseItemKind itemType,
+        [FromQuery, Required] string itemId,
+        [FromQuery, Required] string itemName)
+    {
+        var command = new BrowseRequest
         {
-            var command = new BrowseRequest
-            {
-                ItemId = itemId,
-                ItemName = itemName,
-                ItemType = itemType
-            };
-
-            await _sessionManager.SendBrowseCommand(
-                await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
-                sessionId,
-                command,
-                CancellationToken.None)
-                .ConfigureAwait(false);
-
-            return NoContent();
-        }
+            ItemId = itemId,
+            ItemName = itemName,
+            ItemType = itemType
+        };
+
+        await _sessionManager.SendBrowseCommand(
+            await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
+            sessionId,
+            command,
+            CancellationToken.None)
+            .ConfigureAwait(false);
+
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Instructs a session to play an item.
-        /// </summary>
-        /// <param name="sessionId">The session id.</param>
-        /// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param>
-        /// <param name="itemIds">The ids of the items to play, comma delimited.</param>
-        /// <param name="startPositionTicks">The starting position of the first item.</param>
-        /// <param name="mediaSourceId">Optional. The media source id.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to play.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to play.</param>
-        /// <param name="startIndex">Optional. The start index.</param>
-        /// <response code="204">Instruction sent to session.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/{sessionId}/Playing")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> Play(
-            [FromRoute, Required] string sessionId,
-            [FromQuery, Required] PlayCommand playCommand,
-            [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds,
-            [FromQuery] long? startPositionTicks,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] int? startIndex)
+    /// <summary>
+    /// Instructs a session to play an item.
+    /// </summary>
+    /// <param name="sessionId">The session id.</param>
+    /// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param>
+    /// <param name="itemIds">The ids of the items to play, comma delimited.</param>
+    /// <param name="startPositionTicks">The starting position of the first item.</param>
+    /// <param name="mediaSourceId">Optional. The media source id.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to play.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to play.</param>
+    /// <param name="startIndex">Optional. The start index.</param>
+    /// <response code="204">Instruction sent to session.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/{sessionId}/Playing")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> Play(
+        [FromRoute, Required] string sessionId,
+        [FromQuery, Required] PlayCommand playCommand,
+        [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds,
+        [FromQuery] long? startPositionTicks,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] int? startIndex)
+    {
+        var playRequest = new PlayRequest
         {
-            var playRequest = new PlayRequest
+            ItemIds = itemIds,
+            StartPositionTicks = startPositionTicks,
+            PlayCommand = playCommand,
+            MediaSourceId = mediaSourceId,
+            AudioStreamIndex = audioStreamIndex,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            StartIndex = startIndex
+        };
+
+        await _sessionManager.SendPlayCommand(
+            await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
+            sessionId,
+            playRequest,
+            CancellationToken.None)
+            .ConfigureAwait(false);
+
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Issues a playstate command to a client.
+    /// </summary>
+    /// <param name="sessionId">The session id.</param>
+    /// <param name="command">The <see cref="PlaystateCommand"/>.</param>
+    /// <param name="seekPositionTicks">The optional position ticks.</param>
+    /// <param name="controllingUserId">The optional controlling user id.</param>
+    /// <response code="204">Playstate command sent to session.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/{sessionId}/Playing/{command}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> SendPlaystateCommand(
+        [FromRoute, Required] string sessionId,
+        [FromRoute, Required] PlaystateCommand command,
+        [FromQuery] long? seekPositionTicks,
+        [FromQuery] string? controllingUserId)
+    {
+        await _sessionManager.SendPlaystateCommand(
+            await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
+            sessionId,
+            new PlaystateRequest()
             {
-                ItemIds = itemIds,
-                StartPositionTicks = startPositionTicks,
-                PlayCommand = playCommand,
-                MediaSourceId = mediaSourceId,
-                AudioStreamIndex = audioStreamIndex,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                StartIndex = startIndex
-            };
-
-            await _sessionManager.SendPlayCommand(
-                await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
-                sessionId,
-                playRequest,
-                CancellationToken.None)
-                .ConfigureAwait(false);
-
-            return NoContent();
-        }
+                Command = command,
+                ControllingUserId = controllingUserId,
+                SeekPositionTicks = seekPositionTicks,
+            },
+            CancellationToken.None)
+            .ConfigureAwait(false);
+
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Issues a playstate command to a client.
-        /// </summary>
-        /// <param name="sessionId">The session id.</param>
-        /// <param name="command">The <see cref="PlaystateCommand"/>.</param>
-        /// <param name="seekPositionTicks">The optional position ticks.</param>
-        /// <param name="controllingUserId">The optional controlling user id.</param>
-        /// <response code="204">Playstate command sent to session.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/{sessionId}/Playing/{command}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> SendPlaystateCommand(
-            [FromRoute, Required] string sessionId,
-            [FromRoute, Required] PlaystateCommand command,
-            [FromQuery] long? seekPositionTicks,
-            [FromQuery] string? controllingUserId)
+    /// <summary>
+    /// Issues a system command to a client.
+    /// </summary>
+    /// <param name="sessionId">The session id.</param>
+    /// <param name="command">The command to send.</param>
+    /// <response code="204">System command sent to session.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/{sessionId}/System/{command}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> SendSystemCommand(
+        [FromRoute, Required] string sessionId,
+        [FromRoute, Required] GeneralCommandType command)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var generalCommand = new GeneralCommand
         {
-            await _sessionManager.SendPlaystateCommand(
-                await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
-                sessionId,
-                new PlaystateRequest()
-                {
-                    Command = command,
-                    ControllingUserId = controllingUserId,
-                    SeekPositionTicks = seekPositionTicks,
-                },
-                CancellationToken.None)
-                .ConfigureAwait(false);
-
-            return NoContent();
-        }
+            Name = command,
+            ControllingUserId = currentSession.UserId
+        };
 
-        /// <summary>
-        /// Issues a system command to a client.
-        /// </summary>
-        /// <param name="sessionId">The session id.</param>
-        /// <param name="command">The command to send.</param>
-        /// <response code="204">System command sent to session.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/{sessionId}/System/{command}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> SendSystemCommand(
-            [FromRoute, Required] string sessionId,
-            [FromRoute, Required] GeneralCommandType command)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var generalCommand = new GeneralCommand
-            {
-                Name = command,
-                ControllingUserId = currentSession.UserId
-            };
+        await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None).ConfigureAwait(false);
 
-            await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            return NoContent();
-        }
+    /// <summary>
+    /// Issues a general command to a client.
+    /// </summary>
+    /// <param name="sessionId">The session id.</param>
+    /// <param name="command">The command to send.</param>
+    /// <response code="204">General command sent to session.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/{sessionId}/Command/{command}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> SendGeneralCommand(
+        [FromRoute, Required] string sessionId,
+        [FromRoute, Required] GeneralCommandType command)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
 
-        /// <summary>
-        /// Issues a general command to a client.
-        /// </summary>
-        /// <param name="sessionId">The session id.</param>
-        /// <param name="command">The command to send.</param>
-        /// <response code="204">General command sent to session.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/{sessionId}/Command/{command}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> SendGeneralCommand(
-            [FromRoute, Required] string sessionId,
-            [FromRoute, Required] GeneralCommandType command)
+        var generalCommand = new GeneralCommand
         {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+            Name = command,
+            ControllingUserId = currentSession.UserId
+        };
 
-            var generalCommand = new GeneralCommand
-            {
-                Name = command,
-                ControllingUserId = currentSession.UserId
-            };
+        await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None)
+            .ConfigureAwait(false);
 
-            await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None)
-                .ConfigureAwait(false);
+        return NoContent();
+    }
 
-            return NoContent();
-        }
+    /// <summary>
+    /// Issues a full general command to a client.
+    /// </summary>
+    /// <param name="sessionId">The session id.</param>
+    /// <param name="command">The <see cref="GeneralCommand"/>.</param>
+    /// <response code="204">Full general command sent to session.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/{sessionId}/Command")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> SendFullGeneralCommand(
+        [FromRoute, Required] string sessionId,
+        [FromBody, Required] GeneralCommand command)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
 
-        /// <summary>
-        /// Issues a full general command to a client.
-        /// </summary>
-        /// <param name="sessionId">The session id.</param>
-        /// <param name="command">The <see cref="GeneralCommand"/>.</param>
-        /// <response code="204">Full general command sent to session.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/{sessionId}/Command")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> SendFullGeneralCommand(
-            [FromRoute, Required] string sessionId,
-            [FromBody, Required] GeneralCommand command)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        ArgumentNullException.ThrowIfNull(command);
 
-            ArgumentNullException.ThrowIfNull(command);
+        command.ControllingUserId = currentSession.UserId;
 
-            command.ControllingUserId = currentSession.UserId;
+        await _sessionManager.SendGeneralCommand(
+            currentSession.Id,
+            sessionId,
+            command,
+            CancellationToken.None)
+            .ConfigureAwait(false);
 
-            await _sessionManager.SendGeneralCommand(
-                currentSession.Id,
-                sessionId,
-                command,
-                CancellationToken.None)
-                .ConfigureAwait(false);
+        return NoContent();
+    }
 
-            return NoContent();
+    /// <summary>
+    /// Issues a command to a client to display a message to the user.
+    /// </summary>
+    /// <param name="sessionId">The session id.</param>
+    /// <param name="command">The <see cref="MessageCommand" /> object containing Header, Message Text, and TimeoutMs.</param>
+    /// <response code="204">Message sent.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/{sessionId}/Message")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> SendMessageCommand(
+        [FromRoute, Required] string sessionId,
+        [FromBody, Required] MessageCommand command)
+    {
+        if (string.IsNullOrWhiteSpace(command.Header))
+        {
+            command.Header = "Message from Server";
         }
 
-        /// <summary>
-        /// Issues a command to a client to display a message to the user.
-        /// </summary>
-        /// <param name="sessionId">The session id.</param>
-        /// <param name="command">The <see cref="MessageCommand" /> object containing Header, Message Text, and TimeoutMs.</param>
-        /// <response code="204">Message sent.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/{sessionId}/Message")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> SendMessageCommand(
-            [FromRoute, Required] string sessionId,
-            [FromBody, Required] MessageCommand command)
-        {
-            if (string.IsNullOrWhiteSpace(command.Header))
-            {
-                command.Header = "Message from Server";
-            }
+        await _sessionManager.SendMessageCommand(
+            await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
+            sessionId,
+            command,
+            CancellationToken.None)
+            .ConfigureAwait(false);
 
-            await _sessionManager.SendMessageCommand(
-                await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
-                sessionId,
-                command,
-                CancellationToken.None)
-                .ConfigureAwait(false);
+        return NoContent();
+    }
 
-            return NoContent();
-        }
+    /// <summary>
+    /// Adds an additional user to a session.
+    /// </summary>
+    /// <param name="sessionId">The session id.</param>
+    /// <param name="userId">The user id.</param>
+    /// <response code="204">User added to session.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/{sessionId}/User/{userId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult AddUserToSession(
+        [FromRoute, Required] string sessionId,
+        [FromRoute, Required] Guid userId)
+    {
+        _sessionManager.AddAdditionalUser(sessionId, userId);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Adds an additional user to a session.
-        /// </summary>
-        /// <param name="sessionId">The session id.</param>
-        /// <param name="userId">The user id.</param>
-        /// <response code="204">User added to session.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/{sessionId}/User/{userId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult AddUserToSession(
-            [FromRoute, Required] string sessionId,
-            [FromRoute, Required] Guid userId)
-        {
-            _sessionManager.AddAdditionalUser(sessionId, userId);
-            return NoContent();
-        }
+    /// <summary>
+    /// Removes an additional user from a session.
+    /// </summary>
+    /// <param name="sessionId">The session id.</param>
+    /// <param name="userId">The user id.</param>
+    /// <response code="204">User removed from session.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Sessions/{sessionId}/User/{userId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult RemoveUserFromSession(
+        [FromRoute, Required] string sessionId,
+        [FromRoute, Required] Guid userId)
+    {
+        _sessionManager.RemoveAdditionalUser(sessionId, userId);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Removes an additional user from a session.
-        /// </summary>
-        /// <param name="sessionId">The session id.</param>
-        /// <param name="userId">The user id.</param>
-        /// <response code="204">User removed from session.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Sessions/{sessionId}/User/{userId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult RemoveUserFromSession(
-            [FromRoute, Required] string sessionId,
-            [FromRoute, Required] Guid userId)
+    /// <summary>
+    /// Updates capabilities for a device.
+    /// </summary>
+    /// <param name="id">The session id.</param>
+    /// <param name="playableMediaTypes">A list of playable media types, comma delimited. Audio, Video, Book, Photo.</param>
+    /// <param name="supportedCommands">A list of supported remote control commands, comma delimited.</param>
+    /// <param name="supportsMediaControl">Determines whether media can be played remotely..</param>
+    /// <param name="supportsSync">Determines whether sync is supported.</param>
+    /// <param name="supportsPersistentIdentifier">Determines whether the device supports a unique identifier.</param>
+    /// <response code="204">Capabilities posted.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/Capabilities")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> PostCapabilities(
+        [FromQuery] string? id,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands,
+        [FromQuery] bool supportsMediaControl = false,
+        [FromQuery] bool supportsSync = false,
+        [FromQuery] bool supportsPersistentIdentifier = true)
+    {
+        if (string.IsNullOrWhiteSpace(id))
         {
-            _sessionManager.RemoveAdditionalUser(sessionId, userId);
-            return NoContent();
+            id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
         }
 
-        /// <summary>
-        /// Updates capabilities for a device.
-        /// </summary>
-        /// <param name="id">The session id.</param>
-        /// <param name="playableMediaTypes">A list of playable media types, comma delimited. Audio, Video, Book, Photo.</param>
-        /// <param name="supportedCommands">A list of supported remote control commands, comma delimited.</param>
-        /// <param name="supportsMediaControl">Determines whether media can be played remotely..</param>
-        /// <param name="supportsSync">Determines whether sync is supported.</param>
-        /// <param name="supportsPersistentIdentifier">Determines whether the device supports a unique identifier.</param>
-        /// <response code="204">Capabilities posted.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/Capabilities")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> PostCapabilities(
-            [FromQuery] string? id,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands,
-            [FromQuery] bool supportsMediaControl = false,
-            [FromQuery] bool supportsSync = false,
-            [FromQuery] bool supportsPersistentIdentifier = true)
+        _sessionManager.ReportCapabilities(id, new ClientCapabilities
         {
-            if (string.IsNullOrWhiteSpace(id))
-            {
-                id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            }
-
-            _sessionManager.ReportCapabilities(id, new ClientCapabilities
-            {
-                PlayableMediaTypes = playableMediaTypes,
-                SupportedCommands = supportedCommands,
-                SupportsMediaControl = supportsMediaControl,
-                SupportsSync = supportsSync,
-                SupportsPersistentIdentifier = supportsPersistentIdentifier
-            });
-            return NoContent();
-        }
+            PlayableMediaTypes = playableMediaTypes,
+            SupportedCommands = supportedCommands,
+            SupportsMediaControl = supportsMediaControl,
+            SupportsSync = supportsSync,
+            SupportsPersistentIdentifier = supportsPersistentIdentifier
+        });
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Updates capabilities for a device.
-        /// </summary>
-        /// <param name="id">The session id.</param>
-        /// <param name="capabilities">The <see cref="ClientCapabilities"/>.</param>
-        /// <response code="204">Capabilities updated.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/Capabilities/Full")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> PostFullCapabilities(
-            [FromQuery] string? id,
-            [FromBody, Required] ClientCapabilitiesDto capabilities)
+    /// <summary>
+    /// Updates capabilities for a device.
+    /// </summary>
+    /// <param name="id">The session id.</param>
+    /// <param name="capabilities">The <see cref="ClientCapabilities"/>.</param>
+    /// <response code="204">Capabilities updated.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/Capabilities/Full")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> PostFullCapabilities(
+        [FromQuery] string? id,
+        [FromBody, Required] ClientCapabilitiesDto capabilities)
+    {
+        if (string.IsNullOrWhiteSpace(id))
         {
-            if (string.IsNullOrWhiteSpace(id))
-            {
-                id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            }
+            id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        }
 
-            _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
+        _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
 
-            return NoContent();
-        }
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Reports that a session is viewing an item.
-        /// </summary>
-        /// <param name="sessionId">The session id.</param>
-        /// <param name="itemId">The item id.</param>
-        /// <response code="204">Session reported to server.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/Viewing")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> ReportViewing(
-            [FromQuery] string? sessionId,
-            [FromQuery, Required] string? itemId)
-        {
-            string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+    /// <summary>
+    /// Reports that a session is viewing an item.
+    /// </summary>
+    /// <param name="sessionId">The session id.</param>
+    /// <param name="itemId">The item id.</param>
+    /// <response code="204">Session reported to server.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/Viewing")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> ReportViewing(
+        [FromQuery] string? sessionId,
+        [FromQuery, Required] string? itemId)
+    {
+        string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
 
-            _sessionManager.ReportNowViewingItem(session, itemId);
-            return NoContent();
-        }
+        _sessionManager.ReportNowViewingItem(session, itemId);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Reports that a session has ended.
-        /// </summary>
-        /// <response code="204">Session end reported to server.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Sessions/Logout")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> ReportSessionEnded()
-        {
-            await _sessionManager.Logout(User.GetToken()).ConfigureAwait(false);
-            return NoContent();
-        }
+    /// <summary>
+    /// Reports that a session has ended.
+    /// </summary>
+    /// <response code="204">Session end reported to server.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Sessions/Logout")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> ReportSessionEnded()
+    {
+        await _sessionManager.Logout(User.GetToken()).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Get all auth providers.
-        /// </summary>
-        /// <response code="200">Auth providers retrieved.</response>
-        /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns>
-        [HttpGet("Auth/Providers")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders()
-        {
-            return _userManager.GetAuthenticationProviders();
-        }
+    /// <summary>
+    /// Get all auth providers.
+    /// </summary>
+    /// <response code="200">Auth providers retrieved.</response>
+    /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns>
+    [HttpGet("Auth/Providers")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders()
+    {
+        return _userManager.GetAuthenticationProviders();
+    }
 
-        /// <summary>
-        /// Get all password reset providers.
-        /// </summary>
-        /// <response code="200">Password reset providers retrieved.</response>
-        /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns>
-        [HttpGet("Auth/PasswordResetProviders")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders()
-        {
-            return _userManager.GetPasswordResetProviders();
-        }
+    /// <summary>
+    /// Get all password reset providers.
+    /// </summary>
+    /// <response code="200">Password reset providers retrieved.</response>
+    /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns>
+    [HttpGet("Auth/PasswordResetProviders")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders()
+    {
+        return _userManager.GetPasswordResetProviders();
     }
 }

+ 118 - 119
Jellyfin.Api/Controllers/StartupController.cs

@@ -10,141 +10,140 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The startup wizard controller.
+/// </summary>
+[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+public class StartupController : BaseJellyfinApiController
 {
+    private readonly IServerConfigurationManager _config;
+    private readonly IUserManager _userManager;
+
     /// <summary>
-    /// The startup wizard controller.
+    /// Initializes a new instance of the <see cref="StartupController" /> class.
     /// </summary>
-    [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
-    public class StartupController : BaseJellyfinApiController
+    /// <param name="config">The server configuration manager.</param>
+    /// <param name="userManager">The user manager.</param>
+    public StartupController(IServerConfigurationManager config, IUserManager userManager)
     {
-        private readonly IServerConfigurationManager _config;
-        private readonly IUserManager _userManager;
+        _config = config;
+        _userManager = userManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="StartupController" /> class.
-        /// </summary>
-        /// <param name="config">The server configuration manager.</param>
-        /// <param name="userManager">The user manager.</param>
-        public StartupController(IServerConfigurationManager config, IUserManager userManager)
-        {
-            _config = config;
-            _userManager = userManager;
-        }
+    /// <summary>
+    /// Completes the startup wizard.
+    /// </summary>
+    /// <response code="204">Startup wizard completed.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Complete")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult CompleteWizard()
+    {
+        _config.Configuration.IsStartupWizardCompleted = true;
+        _config.SaveConfiguration();
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Completes the startup wizard.
-        /// </summary>
-        /// <response code="204">Startup wizard completed.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Complete")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult CompleteWizard()
+    /// <summary>
+    /// Gets the initial startup wizard configuration.
+    /// </summary>
+    /// <response code="200">Initial startup wizard configuration retrieved.</response>
+    /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns>
+    [HttpGet("Configuration")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<StartupConfigurationDto> GetStartupConfiguration()
+    {
+        return new StartupConfigurationDto
         {
-            _config.Configuration.IsStartupWizardCompleted = true;
-            _config.SaveConfiguration();
-            return NoContent();
-        }
+            UICulture = _config.Configuration.UICulture,
+            MetadataCountryCode = _config.Configuration.MetadataCountryCode,
+            PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage
+        };
+    }
 
-        /// <summary>
-        /// Gets the initial startup wizard configuration.
-        /// </summary>
-        /// <response code="200">Initial startup wizard configuration retrieved.</response>
-        /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns>
-        [HttpGet("Configuration")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<StartupConfigurationDto> GetStartupConfiguration()
-        {
-            return new StartupConfigurationDto
-            {
-                UICulture = _config.Configuration.UICulture,
-                MetadataCountryCode = _config.Configuration.MetadataCountryCode,
-                PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage
-            };
-        }
+    /// <summary>
+    /// Sets the initial startup wizard configuration.
+    /// </summary>
+    /// <param name="startupConfiguration">The updated startup configuration.</param>
+    /// <response code="204">Configuration saved.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Configuration")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration)
+    {
+        _config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty;
+        _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty;
+        _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty;
+        _config.SaveConfiguration();
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Sets the initial startup wizard configuration.
-        /// </summary>
-        /// <param name="startupConfiguration">The updated startup configuration.</param>
-        /// <response code="204">Configuration saved.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Configuration")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration)
-        {
-            _config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty;
-            _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty;
-            _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty;
-            _config.SaveConfiguration();
-            return NoContent();
-        }
+    /// <summary>
+    /// Sets remote access and UPnP.
+    /// </summary>
+    /// <param name="startupRemoteAccessDto">The startup remote access dto.</param>
+    /// <response code="204">Configuration saved.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("RemoteAccess")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto)
+    {
+        NetworkConfiguration settings = _config.GetNetworkConfiguration();
+        settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess;
+        settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping;
+        _config.SaveConfiguration(NetworkConfigurationStore.StoreKey, settings);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Sets remote access and UPnP.
-        /// </summary>
-        /// <param name="startupRemoteAccessDto">The startup remote access dto.</param>
-        /// <response code="204">Configuration saved.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("RemoteAccess")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto)
+    /// <summary>
+    /// Gets the first user.
+    /// </summary>
+    /// <response code="200">Initial user retrieved.</response>
+    /// <returns>The first user.</returns>
+    [HttpGet("User")]
+    [HttpGet("FirstUser", Name = "GetFirstUser_2")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<StartupUserDto> GetFirstUser()
+    {
+        // TODO: Remove this method when startup wizard no longer requires an existing user.
+        await _userManager.InitializeAsync().ConfigureAwait(false);
+        var user = _userManager.Users.First();
+        return new StartupUserDto
         {
-            NetworkConfiguration settings = _config.GetNetworkConfiguration();
-            settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess;
-            settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping;
-            _config.SaveConfiguration(NetworkConfigurationStore.StoreKey, settings);
-            return NoContent();
-        }
+            Name = user.Username,
+            Password = user.Password
+        };
+    }
 
-        /// <summary>
-        /// Gets the first user.
-        /// </summary>
-        /// <response code="200">Initial user retrieved.</response>
-        /// <returns>The first user.</returns>
-        [HttpGet("User")]
-        [HttpGet("FirstUser", Name = "GetFirstUser_2")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<StartupUserDto> GetFirstUser()
-        {
-            // TODO: Remove this method when startup wizard no longer requires an existing user.
-            await _userManager.InitializeAsync().ConfigureAwait(false);
-            var user = _userManager.Users.First();
-            return new StartupUserDto
-            {
-                Name = user.Username,
-                Password = user.Password
-            };
-        }
+    /// <summary>
+    /// Sets the user name and password.
+    /// </summary>
+    /// <param name="startupUserDto">The DTO containing username and password.</param>
+    /// <response code="204">Updated user name and password.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous update operation.
+    /// The task result contains a <see cref="NoContentResult"/> indicating success.
+    /// </returns>
+    [HttpPost("User")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
+    {
+        var user = _userManager.Users.First();
 
-        /// <summary>
-        /// Sets the user name and password.
-        /// </summary>
-        /// <param name="startupUserDto">The DTO containing username and password.</param>
-        /// <response code="204">Updated user name and password.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous update operation.
-        /// The task result contains a <see cref="NoContentResult"/> indicating success.
-        /// </returns>
-        [HttpPost("User")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
+        if (startupUserDto.Name is not null)
         {
-            var user = _userManager.Users.First();
-
-            if (startupUserDto.Name is not null)
-            {
-                user.Username = startupUserDto.Name;
-            }
-
-            await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+            user.Username = startupUserDto.Name;
+        }
 
-            if (!string.IsNullOrEmpty(startupUserDto.Password))
-            {
-                await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
-            }
+        await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
 
-            return NoContent();
+        if (!string.IsNullOrEmpty(startupUserDto.Password))
+        {
+            await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
         }
+
+        return NoContent();
     }
 }

+ 117 - 118
Jellyfin.Api/Controllers/StudiosController.cs

@@ -16,141 +16,140 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Studios controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class StudiosController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IUserManager _userManager;
+    private readonly IDtoService _dtoService;
+
     /// <summary>
-    /// Studios controller.
+    /// Initializes a new instance of the <see cref="StudiosController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class StudiosController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    public StudiosController(
+        ILibraryManager libraryManager,
+        IUserManager userManager,
+        IDtoService dtoService)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-        private readonly IDtoService _dtoService;
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+        _dtoService = dtoService;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="StudiosController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        public StudiosController(
-            ILibraryManager libraryManager,
-            IUserManager userManager,
-            IDtoService dtoService)
-        {
-            _libraryManager = libraryManager;
-            _userManager = userManager;
-            _dtoService = dtoService;
-        }
+    /// <summary>
+    /// Gets all studios from a given item, folder, or the entire library.
+    /// </summary>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="searchTerm">Optional. Search term.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+    /// <param name="enableUserData">Optional, include user data.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+    /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+    /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <param name="enableTotalRecordCount">Total record count.</param>
+    /// <response code="200">Studios returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the studios.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetStudios(
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] string? searchTerm,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery] bool? isFavorite,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] Guid? userId,
+        [FromQuery] string? nameStartsWithOrGreater,
+        [FromQuery] string? nameStartsWith,
+        [FromQuery] string? nameLessThan,
+        [FromQuery] bool? enableImages = true,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-        /// <summary>
-        /// Gets all studios from a given item, folder, or the entire library.
-        /// </summary>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="searchTerm">Optional. Search term.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="enableUserData">Optional, include user data.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <param name="enableTotalRecordCount">Total record count.</param>
-        /// <response code="200">Studios returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the studios.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetStudios(
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] string? searchTerm,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery] bool? isFavorite,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery] bool? enableImages = true,
-            [FromQuery] bool enableTotalRecordCount = true)
-        {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        User? user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-            User? user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+        var parentItem = _libraryManager.GetParentItem(parentId, userId);
 
-            var parentItem = _libraryManager.GetParentItem(parentId, userId);
+        var query = new InternalItemsQuery(user)
+        {
+            ExcludeItemTypes = excludeItemTypes,
+            IncludeItemTypes = includeItemTypes,
+            StartIndex = startIndex,
+            Limit = limit,
+            IsFavorite = isFavorite,
+            NameLessThan = nameLessThan,
+            NameStartsWith = nameStartsWith,
+            NameStartsWithOrGreater = nameStartsWithOrGreater,
+            DtoOptions = dtoOptions,
+            SearchTerm = searchTerm,
+            EnableTotalRecordCount = enableTotalRecordCount
+        };
 
-            var query = new InternalItemsQuery(user)
+        if (parentId.HasValue)
+        {
+            if (parentItem is Folder)
             {
-                ExcludeItemTypes = excludeItemTypes,
-                IncludeItemTypes = includeItemTypes,
-                StartIndex = startIndex,
-                Limit = limit,
-                IsFavorite = isFavorite,
-                NameLessThan = nameLessThan,
-                NameStartsWith = nameStartsWith,
-                NameStartsWithOrGreater = nameStartsWithOrGreater,
-                DtoOptions = dtoOptions,
-                SearchTerm = searchTerm,
-                EnableTotalRecordCount = enableTotalRecordCount
-            };
-
-            if (parentId.HasValue)
+                query.AncestorIds = new[] { parentId.Value };
+            }
+            else
             {
-                if (parentItem is Folder)
-                {
-                    query.AncestorIds = new[] { parentId.Value };
-                }
-                else
-                {
-                    query.ItemIds = new[] { parentId.Value };
-                }
+                query.ItemIds = new[] { parentId.Value };
             }
-
-            var result = _libraryManager.GetStudios(query);
-            var shouldIncludeItemTypes = includeItemTypes.Length != 0;
-            return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
 
-        /// <summary>
-        /// Gets a studio by name.
-        /// </summary>
-        /// <param name="name">Studio name.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <response code="200">Studio returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the studio.</returns>
-        [HttpGet("{name}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<BaseItemDto> GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId)
-        {
-            var dtoOptions = new DtoOptions().AddClientFields(User);
+        var result = _libraryManager.GetStudios(query);
+        var shouldIncludeItemTypes = includeItemTypes.Length != 0;
+        return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
+    }
 
-            var item = _libraryManager.GetStudio(name);
-            if (userId.HasValue && !userId.Equals(default))
-            {
-                var user = _userManager.GetUserById(userId.Value);
+    /// <summary>
+    /// Gets a studio by name.
+    /// </summary>
+    /// <param name="name">Studio name.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <response code="200">Studio returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the studio.</returns>
+    [HttpGet("{name}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<BaseItemDto> GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId)
+    {
+        var dtoOptions = new DtoOptions().AddClientFields(User);
 
-                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
-            }
+        var item = _libraryManager.GetStudio(name);
+        if (userId.HasValue && !userId.Equals(default))
+        {
+            var user = _userManager.GetUserById(userId.Value);
 
-            return _dtoService.GetBaseItemDto(item, dtoOptions);
+            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
         }
+
+        return _dtoService.GetBaseItemDto(item, dtoOptions);
     }
 }

+ 443 - 444
Jellyfin.Api/Controllers/SubtitleController.cs

@@ -30,522 +30,521 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Subtitle controller.
+/// </summary>
+[Route("")]
+public class SubtitleController : BaseJellyfinApiController
 {
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ISubtitleManager _subtitleManager;
+    private readonly ISubtitleEncoder _subtitleEncoder;
+    private readonly IMediaSourceManager _mediaSourceManager;
+    private readonly IProviderManager _providerManager;
+    private readonly IFileSystem _fileSystem;
+    private readonly ILogger<SubtitleController> _logger;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SubtitleController"/> class.
+    /// </summary>
+    /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param>
+    /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param>
+    /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param>
+    /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
+    /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
+    public SubtitleController(
+        IServerConfigurationManager serverConfigurationManager,
+        ILibraryManager libraryManager,
+        ISubtitleManager subtitleManager,
+        ISubtitleEncoder subtitleEncoder,
+        IMediaSourceManager mediaSourceManager,
+        IProviderManager providerManager,
+        IFileSystem fileSystem,
+        ILogger<SubtitleController> logger)
+    {
+        _serverConfigurationManager = serverConfigurationManager;
+        _libraryManager = libraryManager;
+        _subtitleManager = subtitleManager;
+        _subtitleEncoder = subtitleEncoder;
+        _mediaSourceManager = mediaSourceManager;
+        _providerManager = providerManager;
+        _fileSystem = fileSystem;
+        _logger = logger;
+    }
+
     /// <summary>
-    /// Subtitle controller.
+    /// Deletes an external subtitle file.
     /// </summary>
-    [Route("")]
-    public class SubtitleController : BaseJellyfinApiController
+    /// <param name="itemId">The item id.</param>
+    /// <param name="index">The index of the subtitle file.</param>
+    /// <response code="204">Subtitle deleted.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Videos/{itemId}/Subtitles/{index}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<Task> DeleteSubtitle(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] int index)
     {
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly ISubtitleManager _subtitleManager;
-        private readonly ISubtitleEncoder _subtitleEncoder;
-        private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IProviderManager _providerManager;
-        private readonly IFileSystem _fileSystem;
-        private readonly ILogger<SubtitleController> _logger;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SubtitleController"/> class.
-        /// </summary>
-        /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param>
-        /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param>
-        /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param>
-        /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
-        /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
-        public SubtitleController(
-            IServerConfigurationManager serverConfigurationManager,
-            ILibraryManager libraryManager,
-            ISubtitleManager subtitleManager,
-            ISubtitleEncoder subtitleEncoder,
-            IMediaSourceManager mediaSourceManager,
-            IProviderManager providerManager,
-            IFileSystem fileSystem,
-            ILogger<SubtitleController> logger)
+        var item = _libraryManager.GetItemById(itemId);
+
+        if (item is null)
         {
-            _serverConfigurationManager = serverConfigurationManager;
-            _libraryManager = libraryManager;
-            _subtitleManager = subtitleManager;
-            _subtitleEncoder = subtitleEncoder;
-            _mediaSourceManager = mediaSourceManager;
-            _providerManager = providerManager;
-            _fileSystem = fileSystem;
-            _logger = logger;
+            return NotFound();
         }
 
-        /// <summary>
-        /// Deletes an external subtitle file.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="index">The index of the subtitle file.</param>
-        /// <response code="204">Subtitle deleted.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Videos/{itemId}/Subtitles/{index}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<Task> DeleteSubtitle(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] int index)
-        {
-            var item = _libraryManager.GetItemById(itemId);
+        _subtitleManager.DeleteSubtitles(item, index);
+        return NoContent();
+    }
 
-            if (item is null)
-            {
-                return NotFound();
-            }
+    /// <summary>
+    /// Search remote subtitles.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="language">The language of the subtitles.</param>
+    /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param>
+    /// <response code="200">Subtitles retrieved.</response>
+    /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns>
+    [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] string language,
+        [FromQuery] bool? isPerfectMatch)
+    {
+        var video = (Video)_libraryManager.GetItemById(itemId);
 
-            _subtitleManager.DeleteSubtitles(item, index);
-            return NoContent();
-        }
+        return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false);
+    }
+
+    /// <summary>
+    /// Downloads a remote subtitle.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="subtitleId">The subtitle id.</param>
+    /// <response code="204">Subtitle downloaded.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> DownloadRemoteSubtitles(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] string subtitleId)
+    {
+        var video = (Video)_libraryManager.GetItemById(itemId);
 
-        /// <summary>
-        /// Search remote subtitles.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="language">The language of the subtitles.</param>
-        /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param>
-        /// <response code="200">Subtitles retrieved.</response>
-        /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns>
-        [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] string language,
-            [FromQuery] bool? isPerfectMatch)
+        try
         {
-            var video = (Video)_libraryManager.GetItemById(itemId);
+            await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None)
+                .ConfigureAwait(false);
 
-            return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false);
+            _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
         }
-
-        /// <summary>
-        /// Downloads a remote subtitle.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="subtitleId">The subtitle id.</param>
-        /// <response code="204">Subtitle downloaded.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> DownloadRemoteSubtitles(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] string subtitleId)
+        catch (Exception ex)
         {
-            var video = (Video)_libraryManager.GetItemById(itemId);
+            _logger.LogError(ex, "Error downloading subtitles");
+        }
 
-            try
-            {
-                await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None)
-                    .ConfigureAwait(false);
+        return NoContent();
+    }
 
-                _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error downloading subtitles");
-            }
+    /// <summary>
+    /// Gets the remote subtitles.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <response code="200">File returned.</response>
+    /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns>
+    [HttpGet("Providers/Subtitles/Subtitles/{id}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Produces(MediaTypeNames.Application.Octet)]
+    [ProducesFile("text/*")]
+    public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string id)
+    {
+        var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false);
 
-            return NoContent();
-        }
+        return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format));
+    }
 
-        /// <summary>
-        /// Gets the remote subtitles.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <response code="200">File returned.</response>
-        /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns>
-        [HttpGet("Providers/Subtitles/Subtitles/{id}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Produces(MediaTypeNames.Application.Octet)]
-        [ProducesFile("text/*")]
-        public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string id)
-        {
-            var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false);
+    /// <summary>
+    /// Gets subtitles in a specified format.
+    /// </summary>
+    /// <param name="routeItemId">The (route) item id.</param>
+    /// <param name="routeMediaSourceId">The (route) media source id.</param>
+    /// <param name="routeIndex">The (route) subtitle stream index.</param>
+    /// <param name="routeFormat">The (route) format of the returned subtitle.</param>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="mediaSourceId">The media source id.</param>
+    /// <param name="index">The subtitle stream index.</param>
+    /// <param name="format">The format of the returned subtitle.</param>
+    /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
+    /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
+    /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
+    /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param>
+    /// <response code="200">File returned.</response>
+    /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
+    [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesFile("text/*")]
+    public async Task<ActionResult> GetSubtitle(
+        [FromRoute, Required] Guid routeItemId,
+        [FromRoute, Required] string routeMediaSourceId,
+        [FromRoute, Required] int routeIndex,
+        [FromRoute, Required] string routeFormat,
+        [FromQuery, ParameterObsolete] Guid? itemId,
+        [FromQuery, ParameterObsolete] string? mediaSourceId,
+        [FromQuery, ParameterObsolete] int? index,
+        [FromQuery, ParameterObsolete] string? format,
+        [FromQuery] long? endPositionTicks,
+        [FromQuery] bool copyTimestamps = false,
+        [FromQuery] bool addVttTimeMap = false,
+        [FromQuery] long startPositionTicks = 0)
+    {
+        // Set parameters to route value if not provided via query.
+        itemId ??= routeItemId;
+        mediaSourceId ??= routeMediaSourceId;
+        index ??= routeIndex;
+        format ??= routeFormat;
 
-            return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format));
+        if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
+        {
+            format = "json";
         }
 
-        /// <summary>
-        /// Gets subtitles in a specified format.
-        /// </summary>
-        /// <param name="routeItemId">The (route) item id.</param>
-        /// <param name="routeMediaSourceId">The (route) media source id.</param>
-        /// <param name="routeIndex">The (route) subtitle stream index.</param>
-        /// <param name="routeFormat">The (route) format of the returned subtitle.</param>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="mediaSourceId">The media source id.</param>
-        /// <param name="index">The subtitle stream index.</param>
-        /// <param name="format">The format of the returned subtitle.</param>
-        /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
-        /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
-        /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
-        /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param>
-        /// <response code="200">File returned.</response>
-        /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
-        [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesFile("text/*")]
-        public async Task<ActionResult> GetSubtitle(
-            [FromRoute, Required] Guid routeItemId,
-            [FromRoute, Required] string routeMediaSourceId,
-            [FromRoute, Required] int routeIndex,
-            [FromRoute, Required] string routeFormat,
-            [FromQuery, ParameterObsolete] Guid? itemId,
-            [FromQuery, ParameterObsolete] string? mediaSourceId,
-            [FromQuery, ParameterObsolete] int? index,
-            [FromQuery, ParameterObsolete] string? format,
-            [FromQuery] long? endPositionTicks,
-            [FromQuery] bool copyTimestamps = false,
-            [FromQuery] bool addVttTimeMap = false,
-            [FromQuery] long startPositionTicks = 0)
+        if (string.IsNullOrEmpty(format))
         {
-            // Set parameters to route value if not provided via query.
-            itemId ??= routeItemId;
-            mediaSourceId ??= routeMediaSourceId;
-            index ??= routeIndex;
-            format ??= routeFormat;
-
-            if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
-            {
-                format = "json";
-            }
-
-            if (string.IsNullOrEmpty(format))
-            {
-                var item = (Video)_libraryManager.GetItemById(itemId.Value);
+            var item = (Video)_libraryManager.GetItemById(itemId.Value);
 
-                var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture);
-                var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false)
-                    .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal));
+            var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture);
+            var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false)
+                .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal));
 
-                var subtitleStream = mediaSource.MediaStreams
-                    .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index);
+            var subtitleStream = mediaSource.MediaStreams
+                .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index);
 
-                return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path));
-            }
+            return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path));
+        }
 
-            if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap)
+        if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap)
+        {
+            Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false);
+            await using (stream.ConfigureAwait(false))
             {
-                Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false);
-                await using (stream.ConfigureAwait(false))
-                {
-                    using var reader = new StreamReader(stream);
+                using var reader = new StreamReader(stream);
 
-                    var text = await reader.ReadToEndAsync().ConfigureAwait(false);
+                var text = await reader.ReadToEndAsync().ConfigureAwait(false);
 
-                    text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal);
+                text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal);
 
-                    return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format));
-                }
+                return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format));
             }
-
-            return File(
-                await EncodeSubtitles(
-                    itemId.Value,
-                    mediaSourceId,
-                    index.Value,
-                    format,
-                    startPositionTicks,
-                    endPositionTicks,
-                    copyTimestamps).ConfigureAwait(false),
-                MimeTypes.GetMimeType("file." + format));
         }
 
-        /// <summary>
-        /// Gets subtitles in a specified format.
-        /// </summary>
-        /// <param name="routeItemId">The (route) item id.</param>
-        /// <param name="routeMediaSourceId">The (route) media source id.</param>
-        /// <param name="routeIndex">The (route) subtitle stream index.</param>
-        /// <param name="routeStartPositionTicks">The (route) start position of the subtitle in ticks.</param>
-        /// <param name="routeFormat">The (route) format of the returned subtitle.</param>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="mediaSourceId">The media source id.</param>
-        /// <param name="index">The subtitle stream index.</param>
-        /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param>
-        /// <param name="format">The format of the returned subtitle.</param>
-        /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
-        /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
-        /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
-        /// <response code="200">File returned.</response>
-        /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
-        [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesFile("text/*")]
-        public Task<ActionResult> GetSubtitleWithTicks(
-            [FromRoute, Required] Guid routeItemId,
-            [FromRoute, Required] string routeMediaSourceId,
-            [FromRoute, Required] int routeIndex,
-            [FromRoute, Required] long routeStartPositionTicks,
-            [FromRoute, Required] string routeFormat,
-            [FromQuery, ParameterObsolete] Guid? itemId,
-            [FromQuery, ParameterObsolete] string? mediaSourceId,
-            [FromQuery, ParameterObsolete] int? index,
-            [FromQuery, ParameterObsolete] long? startPositionTicks,
-            [FromQuery, ParameterObsolete] string? format,
-            [FromQuery] long? endPositionTicks,
-            [FromQuery] bool copyTimestamps = false,
-            [FromQuery] bool addVttTimeMap = false)
-        {
-            return GetSubtitle(
-                routeItemId,
-                routeMediaSourceId,
-                routeIndex,
-                routeFormat,
-                itemId,
+        return File(
+            await EncodeSubtitles(
+                itemId.Value,
                 mediaSourceId,
-                index,
+                index.Value,
                 format,
+                startPositionTicks,
                 endPositionTicks,
-                copyTimestamps,
-                addVttTimeMap,
-                startPositionTicks ?? routeStartPositionTicks);
-        }
+                copyTimestamps).ConfigureAwait(false),
+            MimeTypes.GetMimeType("file." + format));
+    }
 
-        /// <summary>
-        /// Gets an HLS subtitle playlist.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="index">The subtitle stream index.</param>
-        /// <param name="mediaSourceId">The media source id.</param>
-        /// <param name="segmentLength">The subtitle segment length.</param>
-        /// <response code="200">Subtitle playlist retrieved.</response>
-        /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns>
-        [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> GetSubtitlePlaylist(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] int index,
-            [FromRoute, Required] string mediaSourceId,
-            [FromQuery, Required] int segmentLength)
-        {
-            var item = (Video)_libraryManager.GetItemById(itemId);
+    /// <summary>
+    /// Gets subtitles in a specified format.
+    /// </summary>
+    /// <param name="routeItemId">The (route) item id.</param>
+    /// <param name="routeMediaSourceId">The (route) media source id.</param>
+    /// <param name="routeIndex">The (route) subtitle stream index.</param>
+    /// <param name="routeStartPositionTicks">The (route) start position of the subtitle in ticks.</param>
+    /// <param name="routeFormat">The (route) format of the returned subtitle.</param>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="mediaSourceId">The media source id.</param>
+    /// <param name="index">The subtitle stream index.</param>
+    /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param>
+    /// <param name="format">The format of the returned subtitle.</param>
+    /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
+    /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
+    /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
+    /// <response code="200">File returned.</response>
+    /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
+    [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesFile("text/*")]
+    public Task<ActionResult> GetSubtitleWithTicks(
+        [FromRoute, Required] Guid routeItemId,
+        [FromRoute, Required] string routeMediaSourceId,
+        [FromRoute, Required] int routeIndex,
+        [FromRoute, Required] long routeStartPositionTicks,
+        [FromRoute, Required] string routeFormat,
+        [FromQuery, ParameterObsolete] Guid? itemId,
+        [FromQuery, ParameterObsolete] string? mediaSourceId,
+        [FromQuery, ParameterObsolete] int? index,
+        [FromQuery, ParameterObsolete] long? startPositionTicks,
+        [FromQuery, ParameterObsolete] string? format,
+        [FromQuery] long? endPositionTicks,
+        [FromQuery] bool copyTimestamps = false,
+        [FromQuery] bool addVttTimeMap = false)
+    {
+        return GetSubtitle(
+            routeItemId,
+            routeMediaSourceId,
+            routeIndex,
+            routeFormat,
+            itemId,
+            mediaSourceId,
+            index,
+            format,
+            endPositionTicks,
+            copyTimestamps,
+            addVttTimeMap,
+            startPositionTicks ?? routeStartPositionTicks);
+    }
 
-            var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false);
+    /// <summary>
+    /// Gets an HLS subtitle playlist.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="index">The subtitle stream index.</param>
+    /// <param name="mediaSourceId">The media source id.</param>
+    /// <param name="segmentLength">The subtitle segment length.</param>
+    /// <response code="200">Subtitle playlist retrieved.</response>
+    /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns>
+    [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> GetSubtitlePlaylist(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] int index,
+        [FromRoute, Required] string mediaSourceId,
+        [FromQuery, Required] int segmentLength)
+    {
+        var item = (Video)_libraryManager.GetItemById(itemId);
 
-            var runtime = mediaSource.RunTimeTicks ?? -1;
+        var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false);
 
-            if (runtime <= 0)
-            {
-                throw new ArgumentException("HLS Subtitles are not supported for this media.");
-            }
+        var runtime = mediaSource.RunTimeTicks ?? -1;
 
-            var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks;
-            if (segmentLengthTicks <= 0)
-            {
-                throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)");
-            }
+        if (runtime <= 0)
+        {
+            throw new ArgumentException("HLS Subtitles are not supported for this media.");
+        }
 
-            var builder = new StringBuilder();
-            builder.AppendLine("#EXTM3U")
-                .Append("#EXT-X-TARGETDURATION:")
-                .Append(segmentLength)
-                .AppendLine()
-                .AppendLine("#EXT-X-VERSION:3")
-                .AppendLine("#EXT-X-MEDIA-SEQUENCE:0")
-                .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
+        var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks;
+        if (segmentLengthTicks <= 0)
+        {
+            throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)");
+        }
 
-            long positionTicks = 0;
+        var builder = new StringBuilder();
+        builder.AppendLine("#EXTM3U")
+            .Append("#EXT-X-TARGETDURATION:")
+            .Append(segmentLength)
+            .AppendLine()
+            .AppendLine("#EXT-X-VERSION:3")
+            .AppendLine("#EXT-X-MEDIA-SEQUENCE:0")
+            .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
 
-            var accessToken = User.GetToken();
+        long positionTicks = 0;
 
-            while (positionTicks < runtime)
-            {
-                var remaining = runtime - positionTicks;
-                var lengthTicks = Math.Min(remaining, segmentLengthTicks);
+        var accessToken = User.GetToken();
 
-                builder.Append("#EXTINF:")
-                    .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds)
-                    .Append(',')
-                    .AppendLine();
+        while (positionTicks < runtime)
+        {
+            var remaining = runtime - positionTicks;
+            var lengthTicks = Math.Min(remaining, segmentLengthTicks);
 
-                var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks);
+            builder.Append("#EXTINF:")
+                .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds)
+                .Append(',')
+                .AppendLine();
 
-                var url = string.Format(
-                    CultureInfo.InvariantCulture,
-                    "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}",
-                    positionTicks.ToString(CultureInfo.InvariantCulture),
-                    endPositionTicks.ToString(CultureInfo.InvariantCulture),
-                    accessToken);
+            var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks);
 
-                builder.AppendLine(url);
+            var url = string.Format(
+                CultureInfo.InvariantCulture,
+                "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}",
+                positionTicks.ToString(CultureInfo.InvariantCulture),
+                endPositionTicks.ToString(CultureInfo.InvariantCulture),
+                accessToken);
 
-                positionTicks += segmentLengthTicks;
-            }
+            builder.AppendLine(url);
 
-            builder.AppendLine("#EXT-X-ENDLIST");
-            return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
+            positionTicks += segmentLengthTicks;
         }
 
-        /// <summary>
-        /// Upload an external subtitle file.
-        /// </summary>
-        /// <param name="itemId">The item the subtitle belongs to.</param>
-        /// <param name="body">The request body.</param>
-        /// <response code="204">Subtitle uploaded.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Videos/{itemId}/Subtitles")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> UploadSubtitle(
-            [FromRoute, Required] Guid itemId,
-            [FromBody, Required] UploadSubtitleDto body)
-        {
-            var video = (Video)_libraryManager.GetItemById(itemId);
-            var data = Convert.FromBase64String(body.Data);
-            var memoryStream = new MemoryStream(data, 0, data.Length, false, true);
-            await using (memoryStream.ConfigureAwait(false))
-            {
-                await _subtitleManager.UploadSubtitle(
-                    video,
-                    new SubtitleResponse
-                    {
-                        Format = body.Format,
-                        Language = body.Language,
-                        IsForced = body.IsForced,
-                        Stream = memoryStream
-                    }).ConfigureAwait(false);
-                _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
-
-                return NoContent();
-            }
-        }
+        builder.AppendLine("#EXT-X-ENDLIST");
+        return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
+    }
 
-        /// <summary>
-        /// Encodes a subtitle in the specified format.
-        /// </summary>
-        /// <param name="id">The media id.</param>
-        /// <param name="mediaSourceId">The source media id.</param>
-        /// <param name="index">The subtitle index.</param>
-        /// <param name="format">The format to convert to.</param>
-        /// <param name="startPositionTicks">The start position in ticks.</param>
-        /// <param name="endPositionTicks">The end position in ticks.</param>
-        /// <param name="copyTimestamps">Whether to copy the timestamps.</param>
-        /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns>
-        private Task<Stream> EncodeSubtitles(
-            Guid id,
-            string? mediaSourceId,
-            int index,
-            string format,
-            long startPositionTicks,
-            long? endPositionTicks,
-            bool copyTimestamps)
+    /// <summary>
+    /// Upload an external subtitle file.
+    /// </summary>
+    /// <param name="itemId">The item the subtitle belongs to.</param>
+    /// <param name="body">The request body.</param>
+    /// <response code="204">Subtitle uploaded.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Videos/{itemId}/Subtitles")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> UploadSubtitle(
+        [FromRoute, Required] Guid itemId,
+        [FromBody, Required] UploadSubtitleDto body)
+    {
+        var video = (Video)_libraryManager.GetItemById(itemId);
+        var data = Convert.FromBase64String(body.Data);
+        var memoryStream = new MemoryStream(data, 0, data.Length, false, true);
+        await using (memoryStream.ConfigureAwait(false))
         {
-            var item = _libraryManager.GetItemById(id);
+            await _subtitleManager.UploadSubtitle(
+                video,
+                new SubtitleResponse
+                {
+                    Format = body.Format,
+                    Language = body.Language,
+                    IsForced = body.IsForced,
+                    Stream = memoryStream
+                }).ConfigureAwait(false);
+            _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
 
-            return _subtitleEncoder.GetSubtitles(
-                item,
-                mediaSourceId,
-                index,
-                format,
-                startPositionTicks,
-                endPositionTicks ?? 0,
-                copyTimestamps,
-                CancellationToken.None);
+            return NoContent();
         }
+    }
 
-        /// <summary>
-        /// Gets a list of available fallback font files.
-        /// </summary>
-        /// <response code="200">Information retrieved.</response>
-        /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns>
-        [HttpGet("FallbackFont/Fonts")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public IEnumerable<FontFile> GetFallbackFontList()
-        {
-            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
-            var fallbackFontPath = encodingOptions.FallbackFontPath;
+    /// <summary>
+    /// Encodes a subtitle in the specified format.
+    /// </summary>
+    /// <param name="id">The media id.</param>
+    /// <param name="mediaSourceId">The source media id.</param>
+    /// <param name="index">The subtitle index.</param>
+    /// <param name="format">The format to convert to.</param>
+    /// <param name="startPositionTicks">The start position in ticks.</param>
+    /// <param name="endPositionTicks">The end position in ticks.</param>
+    /// <param name="copyTimestamps">Whether to copy the timestamps.</param>
+    /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns>
+    private Task<Stream> EncodeSubtitles(
+        Guid id,
+        string? mediaSourceId,
+        int index,
+        string format,
+        long startPositionTicks,
+        long? endPositionTicks,
+        bool copyTimestamps)
+    {
+        var item = _libraryManager.GetItemById(id);
+
+        return _subtitleEncoder.GetSubtitles(
+            item,
+            mediaSourceId,
+            index,
+            format,
+            startPositionTicks,
+            endPositionTicks ?? 0,
+            copyTimestamps,
+            CancellationToken.None);
+    }
+
+    /// <summary>
+    /// Gets a list of available fallback font files.
+    /// </summary>
+    /// <response code="200">Information retrieved.</response>
+    /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns>
+    [HttpGet("FallbackFont/Fonts")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public IEnumerable<FontFile> GetFallbackFontList()
+    {
+        var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+        var fallbackFontPath = encodingOptions.FallbackFontPath;
 
-            if (!string.IsNullOrEmpty(fallbackFontPath))
+        if (!string.IsNullOrEmpty(fallbackFontPath))
+        {
+            var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false);
+            var fontFiles = files
+                .Select(i => new FontFile
+                {
+                    Name = i.Name,
+                    Size = i.Length,
+                    DateCreated = _fileSystem.GetCreationTimeUtc(i),
+                    DateModified = _fileSystem.GetLastWriteTimeUtc(i)
+                })
+                .OrderBy(i => i.Size)
+                .ThenBy(i => i.Name)
+                .ThenByDescending(i => i.DateModified)
+                .ThenByDescending(i => i.DateCreated);
+            // max total size 20M
+            const int MaxSize = 20971520;
+            var sizeCounter = 0L;
+            foreach (var fontFile in fontFiles)
             {
-                var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false);
-                var fontFiles = files
-                    .Select(i => new FontFile
-                    {
-                        Name = i.Name,
-                        Size = i.Length,
-                        DateCreated = _fileSystem.GetCreationTimeUtc(i),
-                        DateModified = _fileSystem.GetLastWriteTimeUtc(i)
-                    })
-                    .OrderBy(i => i.Size)
-                    .ThenBy(i => i.Name)
-                    .ThenByDescending(i => i.DateModified)
-                    .ThenByDescending(i => i.DateCreated);
-                // max total size 20M
-                const int MaxSize = 20971520;
-                var sizeCounter = 0L;
-                foreach (var fontFile in fontFiles)
+                sizeCounter += fontFile.Size;
+                if (sizeCounter >= MaxSize)
                 {
-                    sizeCounter += fontFile.Size;
-                    if (sizeCounter >= MaxSize)
-                    {
-                        _logger.LogWarning("Some fonts will not be sent due to size limitations");
-                        yield break;
-                    }
-
-                    yield return fontFile;
+                    _logger.LogWarning("Some fonts will not be sent due to size limitations");
+                    yield break;
                 }
+
+                yield return fontFile;
             }
-            else
-            {
-                _logger.LogWarning("The path of fallback font folder has not been set");
-                encodingOptions.EnableFallbackFont = false;
-            }
         }
+        else
+        {
+            _logger.LogWarning("The path of fallback font folder has not been set");
+            encodingOptions.EnableFallbackFont = false;
+        }
+    }
 
-        /// <summary>
-        /// Gets a fallback font file.
-        /// </summary>
-        /// <param name="name">The name of the fallback font file to get.</param>
-        /// <response code="200">Fallback font file retrieved.</response>
-        /// <returns>The fallback font file.</returns>
-        [HttpGet("FallbackFont/Fonts/{name}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesFile("font/*")]
-        public ActionResult GetFallbackFont([FromRoute, Required] string name)
+    /// <summary>
+    /// Gets a fallback font file.
+    /// </summary>
+    /// <param name="name">The name of the fallback font file to get.</param>
+    /// <response code="200">Fallback font file retrieved.</response>
+    /// <returns>The fallback font file.</returns>
+    [HttpGet("FallbackFont/Fonts/{name}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesFile("font/*")]
+    public ActionResult GetFallbackFont([FromRoute, Required] string name)
+    {
+        var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+        var fallbackFontPath = encodingOptions.FallbackFontPath;
+
+        if (!string.IsNullOrEmpty(fallbackFontPath))
         {
-            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
-            var fallbackFontPath = encodingOptions.FallbackFontPath;
+            var fontFile = _fileSystem.GetFiles(fallbackFontPath)
+                .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
+            var fileSize = fontFile?.Length;
 
-            if (!string.IsNullOrEmpty(fallbackFontPath))
+            if (fontFile is not null && fileSize is not null && fileSize > 0)
             {
-                var fontFile = _fileSystem.GetFiles(fallbackFontPath)
-                    .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
-                var fileSize = fontFile?.Length;
-
-                if (fontFile is not null && fileSize is not null && fileSize > 0)
-                {
-                    _logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize);
-                    return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName));
-                }
-                else
-                {
-                    _logger.LogWarning("The selected font is null or empty");
-                }
+                _logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize);
+                return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName));
             }
             else
             {
-                _logger.LogWarning("The path of fallback font folder has not been set");
-                encodingOptions.EnableFallbackFont = false;
+                _logger.LogWarning("The selected font is null or empty");
             }
-
-            // returning HTTP 204 will break the SubtitlesOctopus
-            return Ok();
         }
+        else
+        {
+            _logger.LogWarning("The path of fallback font folder has not been set");
+            encodingOptions.EnableFallbackFont = false;
+        }
+
+        // returning HTTP 204 will break the SubtitlesOctopus
+        return Ok();
     }
 }

+ 65 - 66
Jellyfin.Api/Controllers/SuggestionsController.cs

@@ -13,80 +13,79 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The suggestions controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class SuggestionsController : BaseJellyfinApiController
 {
+    private readonly IDtoService _dtoService;
+    private readonly IUserManager _userManager;
+    private readonly ILibraryManager _libraryManager;
+
     /// <summary>
-    /// The suggestions controller.
+    /// Initializes a new instance of the <see cref="SuggestionsController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class SuggestionsController : BaseJellyfinApiController
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    public SuggestionsController(
+        IDtoService dtoService,
+        IUserManager userManager,
+        ILibraryManager libraryManager)
     {
-        private readonly IDtoService _dtoService;
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
+        _dtoService = dtoService;
+        _userManager = userManager;
+        _libraryManager = libraryManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SuggestionsController"/> class.
-        /// </summary>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        public SuggestionsController(
-            IDtoService dtoService,
-            IUserManager userManager,
-            ILibraryManager libraryManager)
-        {
-            _dtoService = dtoService;
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-        }
+    /// <summary>
+    /// Gets suggestions.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="mediaType">The media types.</param>
+    /// <param name="type">The type.</param>
+    /// <param name="startIndex">Optional. The start index.</param>
+    /// <param name="limit">Optional. The limit.</param>
+    /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param>
+    /// <response code="200">Suggestions returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns>
+    [HttpGet("Users/{userId}/Suggestions")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
+        [FromRoute, Required] Guid userId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] bool enableTotalRecordCount = false)
+    {
+        var user = userId.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId);
 
-        /// <summary>
-        /// Gets suggestions.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="mediaType">The media types.</param>
-        /// <param name="type">The type.</param>
-        /// <param name="startIndex">Optional. The start index.</param>
-        /// <param name="limit">Optional. The limit.</param>
-        /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param>
-        /// <response code="200">Suggestions returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns>
-        [HttpGet("Users/{userId}/Suggestions")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
-            [FromRoute, Required] Guid userId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] bool enableTotalRecordCount = false)
+        var dtoOptions = new DtoOptions().AddClientFields(User);
+        var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
         {
-            var user = userId.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId);
-
-            var dtoOptions = new DtoOptions().AddClientFields(User);
-            var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
-            {
-                OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) },
-                MediaTypes = mediaType,
-                IncludeItemTypes = type,
-                IsVirtualItem = false,
-                StartIndex = startIndex,
-                Limit = limit,
-                DtoOptions = dtoOptions,
-                EnableTotalRecordCount = enableTotalRecordCount,
-                Recursive = true
-            });
+            OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) },
+            MediaTypes = mediaType,
+            IncludeItemTypes = type,
+            IsVirtualItem = false,
+            StartIndex = startIndex,
+            Limit = limit,
+            DtoOptions = dtoOptions,
+            EnableTotalRecordCount = enableTotalRecordCount,
+            Recursive = true
+        });
 
-            var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user);
+        var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user);
 
-            return new QueryResult<BaseItemDto>(
-                startIndex,
-                result.TotalRecordCount,
-                dtoList);
-        }
+        return new QueryResult<BaseItemDto>(
+            startIndex,
+            result.TotalRecordCount,
+            dtoList);
     }
 }

+ 378 - 379
Jellyfin.Api/Controllers/SyncPlayController.cs

@@ -16,409 +16,408 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The sync play controller.
+/// </summary>
+[Authorize(Policy = Policies.SyncPlayHasAccess)]
+public class SyncPlayController : BaseJellyfinApiController
 {
+    private readonly ISessionManager _sessionManager;
+    private readonly ISyncPlayManager _syncPlayManager;
+    private readonly IUserManager _userManager;
+
     /// <summary>
-    /// The sync play controller.
+    /// Initializes a new instance of the <see cref="SyncPlayController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.SyncPlayHasAccess)]
-    public class SyncPlayController : BaseJellyfinApiController
+    /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
+    /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    public SyncPlayController(
+        ISessionManager sessionManager,
+        ISyncPlayManager syncPlayManager,
+        IUserManager userManager)
     {
-        private readonly ISessionManager _sessionManager;
-        private readonly ISyncPlayManager _syncPlayManager;
-        private readonly IUserManager _userManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SyncPlayController"/> class.
-        /// </summary>
-        /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
-        /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        public SyncPlayController(
-            ISessionManager sessionManager,
-            ISyncPlayManager syncPlayManager,
-            IUserManager userManager)
-        {
-            _sessionManager = sessionManager;
-            _syncPlayManager = syncPlayManager;
-            _userManager = userManager;
-        }
+        _sessionManager = sessionManager;
+        _syncPlayManager = syncPlayManager;
+        _userManager = userManager;
+    }
 
-        /// <summary>
-        /// Create a new SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The settings of the new group.</param>
-        /// <response code="204">New group created.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("New")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayCreateGroup)]
-        public async Task<ActionResult> SyncPlayCreateGroup(
-            [FromBody, Required] NewGroupRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
-            _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Create a new SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The settings of the new group.</param>
+    /// <response code="204">New group created.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("New")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayCreateGroup)]
+    public async Task<ActionResult> SyncPlayCreateGroup(
+        [FromBody, Required] NewGroupRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
+        _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Join an existing SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The group to join.</param>
-        /// <response code="204">Group join successful.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Join")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayJoinGroup)]
-        public async Task<ActionResult> SyncPlayJoinGroup(
-            [FromBody, Required] JoinGroupRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new JoinGroupRequest(requestData.GroupId);
-            _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Join an existing SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The group to join.</param>
+    /// <response code="204">Group join successful.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Join")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayJoinGroup)]
+    public async Task<ActionResult> SyncPlayJoinGroup(
+        [FromBody, Required] JoinGroupRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new JoinGroupRequest(requestData.GroupId);
+        _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Leave the joined SyncPlay group.
-        /// </summary>
-        /// <response code="204">Group leave successful.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Leave")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlayLeaveGroup()
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new LeaveGroupRequest();
-            _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Leave the joined SyncPlay group.
+    /// </summary>
+    /// <response code="204">Group leave successful.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Leave")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlayLeaveGroup()
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new LeaveGroupRequest();
+        _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Gets all SyncPlay groups.
-        /// </summary>
-        /// <response code="200">Groups returned.</response>
-        /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns>
-        [HttpGet("List")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.SyncPlayJoinGroup)]
-        public async Task<ActionResult<IEnumerable<GroupInfoDto>>> SyncPlayGetGroups()
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new ListGroupsRequest();
-            return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest).AsEnumerable());
-        }
+    /// <summary>
+    /// Gets all SyncPlay groups.
+    /// </summary>
+    /// <response code="200">Groups returned.</response>
+    /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns>
+    [HttpGet("List")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Authorize(Policy = Policies.SyncPlayJoinGroup)]
+    public async Task<ActionResult<IEnumerable<GroupInfoDto>>> SyncPlayGetGroups()
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new ListGroupsRequest();
+        return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest).AsEnumerable());
+    }
 
-        /// <summary>
-        /// Request to set new playlist in SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The new playlist to play in the group.</param>
-        /// <response code="204">Queue update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("SetNewQueue")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlaySetNewQueue(
-            [FromBody, Required] PlayRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new PlayGroupRequest(
-                requestData.PlayingQueue,
-                requestData.PlayingItemPosition,
-                requestData.StartPositionTicks);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request to set new playlist in SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The new playlist to play in the group.</param>
+    /// <response code="204">Queue update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("SetNewQueue")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlaySetNewQueue(
+        [FromBody, Required] PlayRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new PlayGroupRequest(
+            requestData.PlayingQueue,
+            requestData.PlayingItemPosition,
+            requestData.StartPositionTicks);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request to change playlist item in SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The new item to play.</param>
-        /// <response code="204">Queue update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("SetPlaylistItem")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlaySetPlaylistItem(
-            [FromBody, Required] SetPlaylistItemRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request to change playlist item in SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The new item to play.</param>
+    /// <response code="204">Queue update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("SetPlaylistItem")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlaySetPlaylistItem(
+        [FromBody, Required] SetPlaylistItemRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request to remove items from the playlist in SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The items to remove.</param>
-        /// <response code="204">Queue update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("RemoveFromPlaylist")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlayRemoveFromPlaylist(
-            [FromBody, Required] RemoveFromPlaylistRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds, requestData.ClearPlaylist, requestData.ClearPlayingItem);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request to remove items from the playlist in SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The items to remove.</param>
+    /// <response code="204">Queue update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("RemoveFromPlaylist")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlayRemoveFromPlaylist(
+        [FromBody, Required] RemoveFromPlaylistRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds, requestData.ClearPlaylist, requestData.ClearPlayingItem);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request to move an item in the playlist in SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The new position for the item.</param>
-        /// <response code="204">Queue update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("MovePlaylistItem")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlayMovePlaylistItem(
-            [FromBody, Required] MovePlaylistItemRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request to move an item in the playlist in SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The new position for the item.</param>
+    /// <response code="204">Queue update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("MovePlaylistItem")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlayMovePlaylistItem(
+        [FromBody, Required] MovePlaylistItemRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request to queue items to the playlist of a SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The items to add.</param>
-        /// <response code="204">Queue update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Queue")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlayQueue(
-            [FromBody, Required] QueueRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request to queue items to the playlist of a SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The items to add.</param>
+    /// <response code="204">Queue update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Queue")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlayQueue(
+        [FromBody, Required] QueueRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request unpause in SyncPlay group.
-        /// </summary>
-        /// <response code="204">Unpause update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Unpause")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlayUnpause()
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new UnpauseGroupRequest();
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request unpause in SyncPlay group.
+    /// </summary>
+    /// <response code="204">Unpause update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Unpause")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlayUnpause()
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new UnpauseGroupRequest();
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request pause in SyncPlay group.
-        /// </summary>
-        /// <response code="204">Pause update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Pause")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlayPause()
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new PauseGroupRequest();
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request pause in SyncPlay group.
+    /// </summary>
+    /// <response code="204">Pause update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Pause")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlayPause()
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new PauseGroupRequest();
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request stop in SyncPlay group.
-        /// </summary>
-        /// <response code="204">Stop update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Stop")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlayStop()
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new StopGroupRequest();
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request stop in SyncPlay group.
+    /// </summary>
+    /// <response code="204">Stop update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Stop")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlayStop()
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new StopGroupRequest();
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request seek in SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The new playback position.</param>
-        /// <response code="204">Seek update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Seek")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlaySeek(
-            [FromBody, Required] SeekRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request seek in SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The new playback position.</param>
+    /// <response code="204">Seek update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Seek")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlaySeek(
+        [FromBody, Required] SeekRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Notify SyncPlay group that member is buffering.
-        /// </summary>
-        /// <param name="requestData">The player status.</param>
-        /// <response code="204">Group state update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Buffering")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlayBuffering(
-            [FromBody, Required] BufferRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new BufferGroupRequest(
-                requestData.When,
-                requestData.PositionTicks,
-                requestData.IsPlaying,
-                requestData.PlaylistItemId);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Notify SyncPlay group that member is buffering.
+    /// </summary>
+    /// <param name="requestData">The player status.</param>
+    /// <response code="204">Group state update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Buffering")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlayBuffering(
+        [FromBody, Required] BufferRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new BufferGroupRequest(
+            requestData.When,
+            requestData.PositionTicks,
+            requestData.IsPlaying,
+            requestData.PlaylistItemId);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Notify SyncPlay group that member is ready for playback.
-        /// </summary>
-        /// <param name="requestData">The player status.</param>
-        /// <response code="204">Group state update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Ready")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlayReady(
-            [FromBody, Required] ReadyRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new ReadyGroupRequest(
-                requestData.When,
-                requestData.PositionTicks,
-                requestData.IsPlaying,
-                requestData.PlaylistItemId);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Notify SyncPlay group that member is ready for playback.
+    /// </summary>
+    /// <param name="requestData">The player status.</param>
+    /// <response code="204">Group state update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Ready")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlayReady(
+        [FromBody, Required] ReadyRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new ReadyGroupRequest(
+            requestData.When,
+            requestData.PositionTicks,
+            requestData.IsPlaying,
+            requestData.PlaylistItemId);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request SyncPlay group to ignore member during group-wait.
-        /// </summary>
-        /// <param name="requestData">The settings to set.</param>
-        /// <response code="204">Member state updated.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("SetIgnoreWait")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlaySetIgnoreWait(
-            [FromBody, Required] IgnoreWaitRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request SyncPlay group to ignore member during group-wait.
+    /// </summary>
+    /// <param name="requestData">The settings to set.</param>
+    /// <response code="204">Member state updated.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("SetIgnoreWait")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlaySetIgnoreWait(
+        [FromBody, Required] IgnoreWaitRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request next item in SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The current item information.</param>
-        /// <response code="204">Next item update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("NextItem")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlayNextItem(
-            [FromBody, Required] NextItemRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request next item in SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The current item information.</param>
+    /// <response code="204">Next item update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("NextItem")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlayNextItem(
+        [FromBody, Required] NextItemRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request previous item in SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The current item information.</param>
-        /// <response code="204">Previous item update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("PreviousItem")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlayPreviousItem(
-            [FromBody, Required] PreviousItemRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request previous item in SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The current item information.</param>
+    /// <response code="204">Previous item update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("PreviousItem")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlayPreviousItem(
+        [FromBody, Required] PreviousItemRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request to set repeat mode in SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The new repeat mode.</param>
-        /// <response code="204">Play queue update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("SetRepeatMode")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlaySetRepeatMode(
-            [FromBody, Required] SetRepeatModeRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request to set repeat mode in SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The new repeat mode.</param>
+    /// <response code="204">Play queue update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("SetRepeatMode")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlaySetRepeatMode(
+        [FromBody, Required] SetRepeatModeRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Request to set shuffle mode in SyncPlay group.
-        /// </summary>
-        /// <param name="requestData">The new shuffle mode.</param>
-        /// <response code="204">Play queue update sent to all group members.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("SetShuffleMode")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
-        public async Task<ActionResult> SyncPlaySetShuffleMode(
-            [FromBody, Required] SetShuffleModeRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Request to set shuffle mode in SyncPlay group.
+    /// </summary>
+    /// <param name="requestData">The new shuffle mode.</param>
+    /// <response code="204">Play queue update sent to all group members.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("SetShuffleMode")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Authorize(Policy = Policies.SyncPlayIsInGroup)]
+    public async Task<ActionResult> SyncPlaySetShuffleMode(
+        [FromBody, Required] SetShuffleModeRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Update session ping.
-        /// </summary>
-        /// <param name="requestData">The new ping.</param>
-        /// <response code="204">Ping updated.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Ping")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> SyncPlayPing(
-            [FromBody, Required] PingRequestDto requestData)
-        {
-            var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-            var syncPlayRequest = new PingGroupRequest(requestData.Ping);
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-            return NoContent();
-        }
+    /// <summary>
+    /// Update session ping.
+    /// </summary>
+    /// <param name="requestData">The new ping.</param>
+    /// <response code="204">Ping updated.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("Ping")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> SyncPlayPing(
+        [FromBody, Required] PingRequestDto requestData)
+    {
+        var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+        var syncPlayRequest = new PingGroupRequest(requestData.Ping);
+        _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        return NoContent();
     }
 }

+ 177 - 178
Jellyfin.Api/Controllers/SystemController.cs

@@ -20,204 +20,203 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The system controller.
+/// </summary>
+public class SystemController : BaseJellyfinApiController
 {
+    private readonly IServerApplicationHost _appHost;
+    private readonly IApplicationPaths _appPaths;
+    private readonly IFileSystem _fileSystem;
+    private readonly INetworkManager _network;
+    private readonly ILogger<SystemController> _logger;
+
     /// <summary>
-    /// The system controller.
+    /// Initializes a new instance of the <see cref="SystemController"/> class.
     /// </summary>
-    public class SystemController : BaseJellyfinApiController
+    /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
+    /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
+    /// <param name="network">Instance of <see cref="INetworkManager"/> interface.</param>
+    /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param>
+    public SystemController(
+        IServerConfigurationManager serverConfigurationManager,
+        IServerApplicationHost appHost,
+        IFileSystem fileSystem,
+        INetworkManager network,
+        ILogger<SystemController> logger)
     {
-        private readonly IServerApplicationHost _appHost;
-        private readonly IApplicationPaths _appPaths;
-        private readonly IFileSystem _fileSystem;
-        private readonly INetworkManager _network;
-        private readonly ILogger<SystemController> _logger;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SystemController"/> class.
-        /// </summary>
-        /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
-        /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
-        /// <param name="network">Instance of <see cref="INetworkManager"/> interface.</param>
-        /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param>
-        public SystemController(
-            IServerConfigurationManager serverConfigurationManager,
-            IServerApplicationHost appHost,
-            IFileSystem fileSystem,
-            INetworkManager network,
-            ILogger<SystemController> logger)
-        {
-            _appPaths = serverConfigurationManager.ApplicationPaths;
-            _appHost = appHost;
-            _fileSystem = fileSystem;
-            _network = network;
-            _logger = logger;
-        }
+        _appPaths = serverConfigurationManager.ApplicationPaths;
+        _appHost = appHost;
+        _fileSystem = fileSystem;
+        _network = network;
+        _logger = logger;
+    }
 
-        /// <summary>
-        /// Gets information about the server.
-        /// </summary>
-        /// <response code="200">Information retrieved.</response>
-        /// <returns>A <see cref="SystemInfo"/> with info about the system.</returns>
-        [HttpGet("Info")]
-        [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<SystemInfo> GetSystemInfo()
-        {
-            return _appHost.GetSystemInfo(Request);
-        }
+    /// <summary>
+    /// Gets information about the server.
+    /// </summary>
+    /// <response code="200">Information retrieved.</response>
+    /// <returns>A <see cref="SystemInfo"/> with info about the system.</returns>
+    [HttpGet("Info")]
+    [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<SystemInfo> GetSystemInfo()
+    {
+        return _appHost.GetSystemInfo(Request);
+    }
 
-        /// <summary>
-        /// Gets public information about the server.
-        /// </summary>
-        /// <response code="200">Information retrieved.</response>
-        /// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns>
-        [HttpGet("Info/Public")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<PublicSystemInfo> GetPublicSystemInfo()
-        {
-            return _appHost.GetPublicSystemInfo(Request);
-        }
+    /// <summary>
+    /// Gets public information about the server.
+    /// </summary>
+    /// <response code="200">Information retrieved.</response>
+    /// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns>
+    [HttpGet("Info/Public")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<PublicSystemInfo> GetPublicSystemInfo()
+    {
+        return _appHost.GetPublicSystemInfo(Request);
+    }
 
-        /// <summary>
-        /// Pings the system.
-        /// </summary>
-        /// <response code="200">Information retrieved.</response>
-        /// <returns>The server name.</returns>
-        [HttpGet("Ping", Name = "GetPingSystem")]
-        [HttpPost("Ping", Name = "PostPingSystem")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<string> PingSystem()
-        {
-            return _appHost.Name;
-        }
+    /// <summary>
+    /// Pings the system.
+    /// </summary>
+    /// <response code="200">Information retrieved.</response>
+    /// <returns>The server name.</returns>
+    [HttpGet("Ping", Name = "GetPingSystem")]
+    [HttpPost("Ping", Name = "PostPingSystem")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<string> PingSystem()
+    {
+        return _appHost.Name;
+    }
 
-        /// <summary>
-        /// Restarts the application.
-        /// </summary>
-        /// <response code="204">Server restarted.</response>
-        /// <returns>No content. Server restarted.</returns>
-        [HttpPost("Restart")]
-        [Authorize(Policy = Policies.LocalAccessOrRequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult RestartApplication()
+    /// <summary>
+    /// Restarts the application.
+    /// </summary>
+    /// <response code="204">Server restarted.</response>
+    /// <returns>No content. Server restarted.</returns>
+    [HttpPost("Restart")]
+    [Authorize(Policy = Policies.LocalAccessOrRequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult RestartApplication()
+    {
+        Task.Run(async () =>
         {
-            Task.Run(async () =>
-            {
-                await Task.Delay(100).ConfigureAwait(false);
-                _appHost.Restart();
-            });
-            return NoContent();
-        }
+            await Task.Delay(100).ConfigureAwait(false);
+            _appHost.Restart();
+        });
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Shuts down the application.
-        /// </summary>
-        /// <response code="204">Server shut down.</response>
-        /// <returns>No content. Server shut down.</returns>
-        [HttpPost("Shutdown")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult ShutdownApplication()
+    /// <summary>
+    /// Shuts down the application.
+    /// </summary>
+    /// <response code="204">Server shut down.</response>
+    /// <returns>No content. Server shut down.</returns>
+    [HttpPost("Shutdown")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult ShutdownApplication()
+    {
+        Task.Run(async () =>
         {
-            Task.Run(async () =>
-            {
-                await Task.Delay(100).ConfigureAwait(false);
-                await _appHost.Shutdown().ConfigureAwait(false);
-            });
-            return NoContent();
-        }
+            await Task.Delay(100).ConfigureAwait(false);
+            await _appHost.Shutdown().ConfigureAwait(false);
+        });
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Gets a list of available server log files.
+    /// </summary>
+    /// <response code="200">Information retrieved.</response>
+    /// <returns>An array of <see cref="LogFile"/> with the available log files.</returns>
+    [HttpGet("Logs")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<LogFile[]> GetServerLogs()
+    {
+        IEnumerable<FileSystemMetadata> files;
 
-        /// <summary>
-        /// Gets a list of available server log files.
-        /// </summary>
-        /// <response code="200">Information retrieved.</response>
-        /// <returns>An array of <see cref="LogFile"/> with the available log files.</returns>
-        [HttpGet("Logs")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<LogFile[]> GetServerLogs()
+        try
         {
-            IEnumerable<FileSystemMetadata> files;
-
-            try
-            {
-                files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false);
-            }
-            catch (IOException ex)
-            {
-                _logger.LogError(ex, "Error getting logs");
-                files = Enumerable.Empty<FileSystemMetadata>();
-            }
-
-            var result = files.Select(i => new LogFile
-                {
-                    DateCreated = _fileSystem.GetCreationTimeUtc(i),
-                    DateModified = _fileSystem.GetLastWriteTimeUtc(i),
-                    Name = i.Name,
-                    Size = i.Length
-                })
-                .OrderByDescending(i => i.DateModified)
-                .ThenByDescending(i => i.DateCreated)
-                .ThenBy(i => i.Name)
-                .ToArray();
-
-            return result;
+            files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false);
         }
-
-        /// <summary>
-        /// Gets information about the request endpoint.
-        /// </summary>
-        /// <response code="200">Information retrieved.</response>
-        /// <returns><see cref="EndPointInfo"/> with information about the endpoint.</returns>
-        [HttpGet("Endpoint")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<EndPointInfo> GetEndpointInfo()
+        catch (IOException ex)
         {
-            return new EndPointInfo
-            {
-                IsLocal = HttpContext.IsLocal(),
-                IsInNetwork = _network.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp())
-            };
+            _logger.LogError(ex, "Error getting logs");
+            files = Enumerable.Empty<FileSystemMetadata>();
         }
 
-        /// <summary>
-        /// Gets a log file.
-        /// </summary>
-        /// <param name="name">The name of the log file to get.</param>
-        /// <response code="200">Log file retrieved.</response>
-        /// <returns>The log file.</returns>
-        [HttpGet("Logs/Log")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesFile(MediaTypeNames.Text.Plain)]
-        public ActionResult GetLogFile([FromQuery, Required] string name)
+        var result = files.Select(i => new LogFile
         {
-            var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath)
-                .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
+            DateCreated = _fileSystem.GetCreationTimeUtc(i),
+            DateModified = _fileSystem.GetLastWriteTimeUtc(i),
+            Name = i.Name,
+            Size = i.Length
+        })
+            .OrderByDescending(i => i.DateModified)
+            .ThenByDescending(i => i.DateCreated)
+            .ThenBy(i => i.Name)
+            .ToArray();
 
-            // For older files, assume fully static
-            var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite;
-            FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
-            return File(stream, "text/plain; charset=utf-8");
-        }
+        return result;
+    }
 
-        /// <summary>
-        /// Gets wake on lan information.
-        /// </summary>
-        /// <response code="200">Information retrieved.</response>
-        /// <returns>An <see cref="IEnumerable{WakeOnLanInfo}"/> with the WakeOnLan infos.</returns>
-        [HttpGet("WakeOnLanInfo")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [Obsolete("This endpoint is obsolete.")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo()
+    /// <summary>
+    /// Gets information about the request endpoint.
+    /// </summary>
+    /// <response code="200">Information retrieved.</response>
+    /// <returns><see cref="EndPointInfo"/> with information about the endpoint.</returns>
+    [HttpGet("Endpoint")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<EndPointInfo> GetEndpointInfo()
+    {
+        return new EndPointInfo
         {
-            var result = _network.GetMacAddresses()
-                .Select(i => new WakeOnLanInfo(i));
-            return Ok(result);
-        }
+            IsLocal = HttpContext.IsLocal(),
+            IsInNetwork = _network.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp())
+        };
+    }
+
+    /// <summary>
+    /// Gets a log file.
+    /// </summary>
+    /// <param name="name">The name of the log file to get.</param>
+    /// <response code="200">Log file retrieved.</response>
+    /// <returns>The log file.</returns>
+    [HttpGet("Logs/Log")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesFile(MediaTypeNames.Text.Plain)]
+    public ActionResult GetLogFile([FromQuery, Required] string name)
+    {
+        var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath)
+            .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
+
+        // For older files, assume fully static
+        var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite;
+        FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
+        return File(stream, "text/plain; charset=utf-8");
+    }
+
+    /// <summary>
+    /// Gets wake on lan information.
+    /// </summary>
+    /// <response code="200">Information retrieved.</response>
+    /// <returns>An <see cref="IEnumerable{WakeOnLanInfo}"/> with the WakeOnLan infos.</returns>
+    [HttpGet("WakeOnLanInfo")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [Obsolete("This endpoint is obsolete.")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo()
+    {
+        var result = _network.GetMacAddresses()
+            .Select(i => new WakeOnLanInfo(i));
+        return Ok(result);
     }
 }

+ 20 - 21
Jellyfin.Api/Controllers/TimeSyncController.cs

@@ -3,32 +3,31 @@ using MediaBrowser.Model.SyncPlay;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The time sync controller.
+/// </summary>
+[Route("")]
+public class TimeSyncController : BaseJellyfinApiController
 {
     /// <summary>
-    /// The time sync controller.
+    /// Gets the current UTC time.
     /// </summary>
-    [Route("")]
-    public class TimeSyncController : BaseJellyfinApiController
+    /// <response code="200">Time returned.</response>
+    /// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns>
+    [HttpGet("GetUtcTime")]
+    [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
+    public ActionResult<UtcTimeResponse> GetUtcTime()
     {
-        /// <summary>
-        /// Gets the current UTC time.
-        /// </summary>
-        /// <response code="200">Time returned.</response>
-        /// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns>
-        [HttpGet("GetUtcTime")]
-        [ProducesResponseType(statusCode: StatusCodes.Status200OK)]
-        public ActionResult<UtcTimeResponse> GetUtcTime()
-        {
-            // Important to keep the following line at the beginning
-            var requestReceptionTime = DateTime.UtcNow;
+        // Important to keep the following line at the beginning
+        var requestReceptionTime = DateTime.UtcNow;
 
-            // Important to keep the following line at the end
-            var responseTransmissionTime = DateTime.UtcNow;
+        // Important to keep the following line at the end
+        var responseTransmissionTime = DateTime.UtcNow;
 
-            // Implementing NTP on such a high level results in this useless
-            // information being sent. On the other hand it enables future additions.
-            return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime);
-        }
+        // Implementing NTP on such a high level results in this useless
+        // information being sent. On the other hand it enables future additions.
+        return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime);
     }
 }

+ 278 - 279
Jellyfin.Api/Controllers/TrailersController.cs

@@ -10,290 +10,289 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The trailers controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class TrailersController : BaseJellyfinApiController
 {
+    private readonly ItemsController _itemsController;
+
     /// <summary>
-    /// The trailers controller.
+    /// Initializes a new instance of the <see cref="TrailersController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class TrailersController : BaseJellyfinApiController
+    /// <param name="itemsController">Instance of <see cref="ItemsController"/>.</param>
+    public TrailersController(ItemsController itemsController)
     {
-        private readonly ItemsController _itemsController;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="TrailersController"/> class.
-        /// </summary>
-        /// <param name="itemsController">Instance of <see cref="ItemsController"/>.</param>
-        public TrailersController(ItemsController itemsController)
-        {
-            _itemsController = itemsController;
-        }
+        _itemsController = itemsController;
+    }
 
-        /// <summary>
-        /// Finds movies and trailers similar to a given trailer.
-        /// </summary>
-        /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param>
-        /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
-        /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
-        /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
-        /// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
-        /// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
-        /// <param name="hasTrailer">Optional filter by items with trailers.</param>
-        /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
-        /// <param name="parentIndexNumber">Optional filter by parent index number.</param>
-        /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
-        /// <param name="isHd">Optional filter by items that are HD or not.</param>
-        /// <param name="is4K">Optional filter by items that are 4K or not.</param>
-        /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param>
-        /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param>
-        /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
-        /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
-        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
-        /// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
-        /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
-        /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
-        /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
-        /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
-        /// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
-        /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param>
-        /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param>
-        /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param>
-        /// <param name="isMovie">Optional filter for live tv movies.</param>
-        /// <param name="isSeries">Optional filter for live tv series.</param>
-        /// <param name="isNews">Optional filter for live tv news.</param>
-        /// <param name="isKids">Optional filter for live tv kids.</param>
-        /// <param name="isSports">Optional filter for live tv sports.</param>
-        /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
-        /// <param name="searchTerm">Optional. Filter based on a search term.</param>
-        /// <param name="sortOrder">Sort Order - Ascending, Descending.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
-        /// <param name="isPlayed">Optional filter by items that are played, or not.</param>
-        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
-        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
-        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
-        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
-        /// <param name="enableUserData">Optional, include user data.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
-        /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param>
-        /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param>
-        /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
-        /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
-        /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
-        /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param>
-        /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param>
-        /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
-        /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param>
-        /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
-        /// <param name="isLocked">Optional filter by items that are locked.</param>
-        /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
-        /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
-        /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
-        /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
-        /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
-        /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
-        /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
-        /// <param name="is3D">Optional filter by items that are 3D, or not.</param>
-        /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param>
-        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
-        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
-        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
-        /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetTrailers(
-            [FromQuery] Guid? userId,
-            [FromQuery] string? maxOfficialRating,
-            [FromQuery] bool? hasThemeSong,
-            [FromQuery] bool? hasThemeVideo,
-            [FromQuery] bool? hasSubtitles,
-            [FromQuery] bool? hasSpecialFeature,
-            [FromQuery] bool? hasTrailer,
-            [FromQuery] Guid? adjacentTo,
-            [FromQuery] int? parentIndexNumber,
-            [FromQuery] bool? hasParentalRating,
-            [FromQuery] bool? isHd,
-            [FromQuery] bool? is4K,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
-            [FromQuery] bool? isMissing,
-            [FromQuery] bool? isUnaired,
-            [FromQuery] double? minCommunityRating,
-            [FromQuery] double? minCriticRating,
-            [FromQuery] DateTime? minPremiereDate,
-            [FromQuery] DateTime? minDateLastSaved,
-            [FromQuery] DateTime? minDateLastSavedForUser,
-            [FromQuery] DateTime? maxPremiereDate,
-            [FromQuery] bool? hasOverview,
-            [FromQuery] bool? hasImdbId,
-            [FromQuery] bool? hasTmdbId,
-            [FromQuery] bool? hasTvdbId,
-            [FromQuery] bool? isMovie,
-            [FromQuery] bool? isSeries,
-            [FromQuery] bool? isNews,
-            [FromQuery] bool? isKids,
-            [FromQuery] bool? isSports,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] bool? recursive,
-            [FromQuery] string? searchTerm,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
-            [FromQuery] bool? isFavorite,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery] bool? isPlayed,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
-            [FromQuery] string? minOfficialRating,
-            [FromQuery] bool? isLocked,
-            [FromQuery] bool? isPlaceHolder,
-            [FromQuery] bool? hasOfficialRating,
-            [FromQuery] bool? collapseBoxSetItems,
-            [FromQuery] int? minWidth,
-            [FromQuery] int? minHeight,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] bool? is3D,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
-            [FromQuery] bool enableTotalRecordCount = true,
-            [FromQuery] bool? enableImages = true)
-        {
-            var includeItemTypes = new[] { BaseItemKind.Trailer };
+    /// <summary>
+    /// Finds movies and trailers similar to a given trailer.
+    /// </summary>
+    /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param>
+    /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
+    /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
+    /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
+    /// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
+    /// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
+    /// <param name="hasTrailer">Optional filter by items with trailers.</param>
+    /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+    /// <param name="parentIndexNumber">Optional filter by parent index number.</param>
+    /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
+    /// <param name="isHd">Optional filter by items that are HD or not.</param>
+    /// <param name="is4K">Optional filter by items that are 4K or not.</param>
+    /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param>
+    /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param>
+    /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
+    /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
+    /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+    /// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
+    /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
+    /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
+    /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
+    /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
+    /// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
+    /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param>
+    /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param>
+    /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param>
+    /// <param name="isMovie">Optional filter for live tv movies.</param>
+    /// <param name="isSeries">Optional filter for live tv series.</param>
+    /// <param name="isNews">Optional filter for live tv news.</param>
+    /// <param name="isKids">Optional filter for live tv kids.</param>
+    /// <param name="isSports">Optional filter for live tv sports.</param>
+    /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
+    /// <param name="searchTerm">Optional. Filter based on a search term.</param>
+    /// <param name="sortOrder">Sort Order - Ascending, Descending.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+    /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+    /// <param name="isPlayed">Optional filter by items that are played, or not.</param>
+    /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+    /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+    /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+    /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
+    /// <param name="enableUserData">Optional, include user data.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+    /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
+    /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+    /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+    /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param>
+    /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param>
+    /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
+    /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
+    /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
+    /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param>
+    /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param>
+    /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
+    /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param>
+    /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
+    /// <param name="isLocked">Optional filter by items that are locked.</param>
+    /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
+    /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
+    /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
+    /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
+    /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
+    /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
+    /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
+    /// <param name="is3D">Optional filter by items that are 3D, or not.</param>
+    /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param>
+    /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+    /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+    /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+    /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+    /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
+    /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetTrailers(
+        [FromQuery] Guid? userId,
+        [FromQuery] string? maxOfficialRating,
+        [FromQuery] bool? hasThemeSong,
+        [FromQuery] bool? hasThemeVideo,
+        [FromQuery] bool? hasSubtitles,
+        [FromQuery] bool? hasSpecialFeature,
+        [FromQuery] bool? hasTrailer,
+        [FromQuery] Guid? adjacentTo,
+        [FromQuery] int? parentIndexNumber,
+        [FromQuery] bool? hasParentalRating,
+        [FromQuery] bool? isHd,
+        [FromQuery] bool? is4K,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
+        [FromQuery] bool? isMissing,
+        [FromQuery] bool? isUnaired,
+        [FromQuery] double? minCommunityRating,
+        [FromQuery] double? minCriticRating,
+        [FromQuery] DateTime? minPremiereDate,
+        [FromQuery] DateTime? minDateLastSaved,
+        [FromQuery] DateTime? minDateLastSavedForUser,
+        [FromQuery] DateTime? maxPremiereDate,
+        [FromQuery] bool? hasOverview,
+        [FromQuery] bool? hasImdbId,
+        [FromQuery] bool? hasTmdbId,
+        [FromQuery] bool? hasTvdbId,
+        [FromQuery] bool? isMovie,
+        [FromQuery] bool? isSeries,
+        [FromQuery] bool? isNews,
+        [FromQuery] bool? isKids,
+        [FromQuery] bool? isSports,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] bool? recursive,
+        [FromQuery] string? searchTerm,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery] bool? isFavorite,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery] bool? isPlayed,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] string? person,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
+        [FromQuery] string? minOfficialRating,
+        [FromQuery] bool? isLocked,
+        [FromQuery] bool? isPlaceHolder,
+        [FromQuery] bool? hasOfficialRating,
+        [FromQuery] bool? collapseBoxSetItems,
+        [FromQuery] int? minWidth,
+        [FromQuery] int? minHeight,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] bool? is3D,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
+        [FromQuery] string? nameStartsWithOrGreater,
+        [FromQuery] string? nameStartsWith,
+        [FromQuery] string? nameLessThan,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery] bool enableTotalRecordCount = true,
+        [FromQuery] bool? enableImages = true)
+    {
+        var includeItemTypes = new[] { BaseItemKind.Trailer };
 
-            return _itemsController
-                .GetItems(
-                    userId,
-                    maxOfficialRating,
-                    hasThemeSong,
-                    hasThemeVideo,
-                    hasSubtitles,
-                    hasSpecialFeature,
-                    hasTrailer,
-                    adjacentTo,
-                    parentIndexNumber,
-                    hasParentalRating,
-                    isHd,
-                    is4K,
-                    locationTypes,
-                    excludeLocationTypes,
-                    isMissing,
-                    isUnaired,
-                    minCommunityRating,
-                    minCriticRating,
-                    minPremiereDate,
-                    minDateLastSaved,
-                    minDateLastSavedForUser,
-                    maxPremiereDate,
-                    hasOverview,
-                    hasImdbId,
-                    hasTmdbId,
-                    hasTvdbId,
-                    isMovie,
-                    isSeries,
-                    isNews,
-                    isKids,
-                    isSports,
-                    excludeItemIds,
-                    startIndex,
-                    limit,
-                    recursive,
-                    searchTerm,
-                    sortOrder,
-                    parentId,
-                    fields,
-                    excludeItemTypes,
-                    includeItemTypes,
-                    filters,
-                    isFavorite,
-                    mediaTypes,
-                    imageTypes,
-                    sortBy,
-                    isPlayed,
-                    genres,
-                    officialRatings,
-                    tags,
-                    years,
-                    enableUserData,
-                    imageTypeLimit,
-                    enableImageTypes,
-                    person,
-                    personIds,
-                    personTypes,
-                    studios,
-                    artists,
-                    excludeArtistIds,
-                    artistIds,
-                    albumArtistIds,
-                    contributingArtistIds,
-                    albums,
-                    albumIds,
-                    ids,
-                    videoTypes,
-                    minOfficialRating,
-                    isLocked,
-                    isPlaceHolder,
-                    hasOfficialRating,
-                    collapseBoxSetItems,
-                    minWidth,
-                    minHeight,
-                    maxWidth,
-                    maxHeight,
-                    is3D,
-                    seriesStatus,
-                    nameStartsWithOrGreater,
-                    nameStartsWith,
-                    nameLessThan,
-                    studioIds,
-                    genreIds,
-                    enableTotalRecordCount,
-                    enableImages);
-        }
+        return _itemsController
+            .GetItems(
+                userId,
+                maxOfficialRating,
+                hasThemeSong,
+                hasThemeVideo,
+                hasSubtitles,
+                hasSpecialFeature,
+                hasTrailer,
+                adjacentTo,
+                parentIndexNumber,
+                hasParentalRating,
+                isHd,
+                is4K,
+                locationTypes,
+                excludeLocationTypes,
+                isMissing,
+                isUnaired,
+                minCommunityRating,
+                minCriticRating,
+                minPremiereDate,
+                minDateLastSaved,
+                minDateLastSavedForUser,
+                maxPremiereDate,
+                hasOverview,
+                hasImdbId,
+                hasTmdbId,
+                hasTvdbId,
+                isMovie,
+                isSeries,
+                isNews,
+                isKids,
+                isSports,
+                excludeItemIds,
+                startIndex,
+                limit,
+                recursive,
+                searchTerm,
+                sortOrder,
+                parentId,
+                fields,
+                excludeItemTypes,
+                includeItemTypes,
+                filters,
+                isFavorite,
+                mediaTypes,
+                imageTypes,
+                sortBy,
+                isPlayed,
+                genres,
+                officialRatings,
+                tags,
+                years,
+                enableUserData,
+                imageTypeLimit,
+                enableImageTypes,
+                person,
+                personIds,
+                personTypes,
+                studios,
+                artists,
+                excludeArtistIds,
+                artistIds,
+                albumArtistIds,
+                contributingArtistIds,
+                albums,
+                albumIds,
+                ids,
+                videoTypes,
+                minOfficialRating,
+                isLocked,
+                isPlaceHolder,
+                hasOfficialRating,
+                collapseBoxSetItems,
+                minWidth,
+                minHeight,
+                maxWidth,
+                maxHeight,
+                is3D,
+                seriesStatus,
+                nameStartsWithOrGreater,
+                nameStartsWith,
+                nameLessThan,
+                studioIds,
+                genreIds,
+                enableTotalRecordCount,
+                enableImages);
     }
 }

+ 316 - 317
Jellyfin.Api/Controllers/TvShowsController.cs

@@ -19,366 +19,365 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The tv shows controller.
+/// </summary>
+[Route("Shows")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class TvShowsController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IDtoService _dtoService;
+    private readonly ITVSeriesManager _tvSeriesManager;
+
     /// <summary>
-    /// The tv shows controller.
+    /// Initializes a new instance of the <see cref="TvShowsController"/> class.
     /// </summary>
-    [Route("Shows")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class TvShowsController : BaseJellyfinApiController
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="tvSeriesManager">Instance of the <see cref="ITVSeriesManager"/> interface.</param>
+    public TvShowsController(
+        IUserManager userManager,
+        ILibraryManager libraryManager,
+        IDtoService dtoService,
+        ITVSeriesManager tvSeriesManager)
     {
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-        private readonly ITVSeriesManager _tvSeriesManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="TvShowsController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="tvSeriesManager">Instance of the <see cref="ITVSeriesManager"/> interface.</param>
-        public TvShowsController(
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IDtoService dtoService,
-            ITVSeriesManager tvSeriesManager)
-        {
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _dtoService = dtoService;
-            _tvSeriesManager = tvSeriesManager;
-        }
-
-        /// <summary>
-        /// Gets a list of next up episodes.
-        /// </summary>
-        /// <param name="userId">The user id of the user to get the next up episodes for.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="seriesId">Optional. Filter by series id.</param>
-        /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param>
-        /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param>
-        /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param>
-        /// <param name="enableRewatching">Whether to include watched episode in next up results.</param>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
-        [HttpGet("NextUp")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetNextUp(
-            [FromQuery] Guid? userId,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] Guid? seriesId,
-            [FromQuery] Guid? parentId,
-            [FromQuery] bool? enableImages,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] DateTime? nextUpDateCutoff,
-            [FromQuery] bool enableTotalRecordCount = true,
-            [FromQuery] bool disableFirstEpisode = false,
-            [FromQuery] bool enableRewatching = false)
-        {
-            var options = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-
-            var result = _tvSeriesManager.GetNextUp(
-                new NextUpQuery
-                {
-                    Limit = limit,
-                    ParentId = parentId,
-                    SeriesId = seriesId,
-                    StartIndex = startIndex,
-                    UserId = userId ?? Guid.Empty,
-                    EnableTotalRecordCount = enableTotalRecordCount,
-                    DisableFirstEpisode = disableFirstEpisode,
-                    NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue,
-                    EnableRewatching = enableRewatching
-                },
-                options);
-
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-
-            var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user);
-
-            return new QueryResult<BaseItemDto>(
-                startIndex,
-                result.TotalRecordCount,
-                returnItems);
-        }
-
-        /// <summary>
-        /// Gets a list of upcoming episodes.
-        /// </summary>
-        /// <param name="userId">The user id of the user to get the upcoming episodes for.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
-        [HttpGet("Upcoming")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes(
-            [FromQuery] Guid? userId,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] Guid? parentId,
-            [FromQuery] bool? enableImages,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] bool? enableUserData)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-
-            var minPremiereDate = DateTime.UtcNow.Date.AddDays(-1);
-
-            var parentIdGuid = parentId ?? Guid.Empty;
+        _userManager = userManager;
+        _libraryManager = libraryManager;
+        _dtoService = dtoService;
+        _tvSeriesManager = tvSeriesManager;
+    }
 
-            var options = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+    /// <summary>
+    /// Gets a list of next up episodes.
+    /// </summary>
+    /// <param name="userId">The user id of the user to get the next up episodes for.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="seriesId">Optional. Filter by series id.</param>
+    /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param>
+    /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param>
+    /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param>
+    /// <param name="enableRewatching">Whether to include watched episode in next up results.</param>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
+    [HttpGet("NextUp")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetNextUp(
+        [FromQuery] Guid? userId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] Guid? seriesId,
+        [FromQuery] Guid? parentId,
+        [FromQuery] bool? enableImages,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] DateTime? nextUpDateCutoff,
+        [FromQuery] bool enableTotalRecordCount = true,
+        [FromQuery] bool disableFirstEpisode = false,
+        [FromQuery] bool enableRewatching = false)
+    {
+        var options = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-            var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user)
+        var result = _tvSeriesManager.GetNextUp(
+            new NextUpQuery
             {
-                IncludeItemTypes = new[] { BaseItemKind.Episode },
-                OrderBy = new[] { (ItemSortBy.PremiereDate, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) },
-                MinPremiereDate = minPremiereDate,
-                StartIndex = startIndex,
                 Limit = limit,
-                ParentId = parentIdGuid,
-                Recursive = true,
-                DtoOptions = options
-            });
+                ParentId = parentId,
+                SeriesId = seriesId,
+                StartIndex = startIndex,
+                UserId = userId ?? Guid.Empty,
+                EnableTotalRecordCount = enableTotalRecordCount,
+                DisableFirstEpisode = disableFirstEpisode,
+                NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue,
+                EnableRewatching = enableRewatching
+            },
+            options);
+
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+
+        var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user);
+
+        return new QueryResult<BaseItemDto>(
+            startIndex,
+            result.TotalRecordCount,
+            returnItems);
+    }
 
-            var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user);
+    /// <summary>
+    /// Gets a list of upcoming episodes.
+    /// </summary>
+    /// <param name="userId">The user id of the user to get the upcoming episodes for.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
+    [HttpGet("Upcoming")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes(
+        [FromQuery] Guid? userId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] Guid? parentId,
+        [FromQuery] bool? enableImages,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] bool? enableUserData)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-            return new QueryResult<BaseItemDto>(
-                startIndex,
-                itemsResult.Count,
-                returnItems);
-        }
+        var minPremiereDate = DateTime.UtcNow.Date.AddDays(-1);
 
-        /// <summary>
-        /// Gets episodes for a tv season.
-        /// </summary>
-        /// <param name="seriesId">The series id.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
-        /// <param name="season">Optional filter by season number.</param>
-        /// <param name="seasonId">Optional. Filter by season id.</param>
-        /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
-        /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
-        /// <param name="startItemId">Optional. Skip through the list until a given item is found.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
-        [HttpGet("{seriesId}/Episodes")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
-            [FromRoute, Required] Guid seriesId,
-            [FromQuery] Guid? userId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] int? season,
-            [FromQuery] Guid? seasonId,
-            [FromQuery] bool? isMissing,
-            [FromQuery] Guid? adjacentTo,
-            [FromQuery] Guid? startItemId,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] bool? enableImages,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] string? sortBy)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+        var parentIdGuid = parentId ?? Guid.Empty;
 
-            List<BaseItem> episodes;
+        var options = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user)
+        {
+            IncludeItemTypes = new[] { BaseItemKind.Episode },
+            OrderBy = new[] { (ItemSortBy.PremiereDate, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) },
+            MinPremiereDate = minPremiereDate,
+            StartIndex = startIndex,
+            Limit = limit,
+            ParentId = parentIdGuid,
+            Recursive = true,
+            DtoOptions = options
+        });
+
+        var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user);
+
+        return new QueryResult<BaseItemDto>(
+            startIndex,
+            itemsResult.Count,
+            returnItems);
+    }
 
-            if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
-            {
-                var item = _libraryManager.GetItemById(seasonId.Value);
-                if (item is not Season seasonItem)
-                {
-                    return NotFound("No season exists with Id " + seasonId);
-                }
+    /// <summary>
+    /// Gets episodes for a tv season.
+    /// </summary>
+    /// <param name="seriesId">The series id.</param>
+    /// <param name="userId">The user id.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+    /// <param name="season">Optional filter by season number.</param>
+    /// <param name="seasonId">Optional. Filter by season id.</param>
+    /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
+    /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+    /// <param name="startItemId">Optional. Skip through the list until a given item is found.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
+    [HttpGet("{seriesId}/Episodes")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
+        [FromRoute, Required] Guid seriesId,
+        [FromQuery] Guid? userId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] int? season,
+        [FromQuery] Guid? seasonId,
+        [FromQuery] bool? isMissing,
+        [FromQuery] Guid? adjacentTo,
+        [FromQuery] Guid? startItemId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] bool? enableImages,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] string? sortBy)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-                episodes = seasonItem.GetEpisodes(user, dtoOptions);
-            }
-            else if (season.HasValue) // Season number was supplied. Get episodes by season number
-            {
-                if (_libraryManager.GetItemById(seriesId) is not Series series)
-                {
-                    return NotFound("Series not found");
-                }
-
-                var seasonItem = series
-                    .GetSeasons(user, dtoOptions)
-                    .FirstOrDefault(i => i.IndexNumber == season.Value);
-
-                episodes = seasonItem is null ?
-                    new List<BaseItem>()
-                    : ((Season)seasonItem).GetEpisodes(user, dtoOptions);
-            }
-            else // No season number or season id was supplied. Returning all episodes.
-            {
-                if (_libraryManager.GetItemById(seriesId) is not Series series)
-                {
-                    return NotFound("Series not found");
-                }
+        List<BaseItem> episodes;
 
-                episodes = series.GetEpisodes(user, dtoOptions).ToList();
-            }
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-            // Filter after the fact in case the ui doesn't want them
-            if (isMissing.HasValue)
+        if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
+        {
+            var item = _libraryManager.GetItemById(seasonId.Value);
+            if (item is not Season seasonItem)
             {
-                var val = isMissing.Value;
-                episodes = episodes
-                    .Where(i => ((Episode)i).IsMissingEpisode == val)
-                    .ToList();
+                return NotFound("No season exists with Id " + seasonId);
             }
 
-            if (startItemId.HasValue)
+            episodes = seasonItem.GetEpisodes(user, dtoOptions);
+        }
+        else if (season.HasValue) // Season number was supplied. Get episodes by season number
+        {
+            if (_libraryManager.GetItemById(seriesId) is not Series series)
             {
-                episodes = episodes
-                    .SkipWhile(i => !startItemId.Value.Equals(i.Id))
-                    .ToList();
+                return NotFound("Series not found");
             }
 
-            // This must be the last filter
-            if (adjacentTo.HasValue && !adjacentTo.Value.Equals(default))
-            {
-                episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo.Value).ToList();
-            }
+            var seasonItem = series
+                .GetSeasons(user, dtoOptions)
+                .FirstOrDefault(i => i.IndexNumber == season.Value);
 
-            if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase))
+            episodes = seasonItem is null ?
+                new List<BaseItem>()
+                : ((Season)seasonItem).GetEpisodes(user, dtoOptions);
+        }
+        else // No season number or season id was supplied. Returning all episodes.
+        {
+            if (_libraryManager.GetItemById(seriesId) is not Series series)
             {
-                episodes.Shuffle();
+                return NotFound("Series not found");
             }
 
-            var returnItems = episodes;
+            episodes = series.GetEpisodes(user, dtoOptions).ToList();
+        }
 
-            if (startIndex.HasValue || limit.HasValue)
-            {
-                returnItems = ApplyPaging(episodes, startIndex, limit).ToList();
-            }
+        // Filter after the fact in case the ui doesn't want them
+        if (isMissing.HasValue)
+        {
+            var val = isMissing.Value;
+            episodes = episodes
+                .Where(i => ((Episode)i).IsMissingEpisode == val)
+                .ToList();
+        }
 
-            var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
+        if (startItemId.HasValue)
+        {
+            episodes = episodes
+                .SkipWhile(i => !startItemId.Value.Equals(i.Id))
+                .ToList();
+        }
 
-            return new QueryResult<BaseItemDto>(
-                startIndex,
-                episodes.Count,
-                dtos);
+        // This must be the last filter
+        if (adjacentTo.HasValue && !adjacentTo.Value.Equals(default))
+        {
+            episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo.Value).ToList();
         }
 
-        /// <summary>
-        /// Gets seasons for a tv series.
-        /// </summary>
-        /// <param name="seriesId">The series id.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
-        /// <param name="isSpecialSeason">Optional. Filter by special season.</param>
-        /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
-        /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
-        [HttpGet("{seriesId}/Seasons")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
-            [FromRoute, Required] Guid seriesId,
-            [FromQuery] Guid? userId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? isSpecialSeason,
-            [FromQuery] bool? isMissing,
-            [FromQuery] Guid? adjacentTo,
-            [FromQuery] bool? enableImages,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] bool? enableUserData)
+        if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase))
         {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+            episodes.Shuffle();
+        }
 
-            if (_libraryManager.GetItemById(seriesId) is not Series series)
-            {
-                return NotFound("Series not found");
-            }
+        var returnItems = episodes;
 
-            var seasons = series.GetItemList(new InternalItemsQuery(user)
-            {
-                IsMissing = isMissing,
-                IsSpecialSeason = isSpecialSeason,
-                AdjacentTo = adjacentTo
-            });
+        if (startIndex.HasValue || limit.HasValue)
+        {
+            returnItems = ApplyPaging(episodes, startIndex, limit).ToList();
+        }
+
+        var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
 
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        return new QueryResult<BaseItemDto>(
+            startIndex,
+            episodes.Count,
+            dtos);
+    }
 
-            var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user);
+    /// <summary>
+    /// Gets seasons for a tv series.
+    /// </summary>
+    /// <param name="seriesId">The series id.</param>
+    /// <param name="userId">The user id.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+    /// <param name="isSpecialSeason">Optional. Filter by special season.</param>
+    /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
+    /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
+    [HttpGet("{seriesId}/Seasons")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
+        [FromRoute, Required] Guid seriesId,
+        [FromQuery] Guid? userId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? isSpecialSeason,
+        [FromQuery] bool? isMissing,
+        [FromQuery] Guid? adjacentTo,
+        [FromQuery] bool? enableImages,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] bool? enableUserData)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-            return new QueryResult<BaseItemDto>(returnItems);
+        if (_libraryManager.GetItemById(seriesId) is not Series series)
+        {
+            return NotFound("Series not found");
         }
 
-        /// <summary>
-        /// Applies the paging.
-        /// </summary>
-        /// <param name="items">The items.</param>
-        /// <param name="startIndex">The start index.</param>
-        /// <param name="limit">The limit.</param>
-        /// <returns>IEnumerable{BaseItem}.</returns>
-        private IEnumerable<BaseItem> ApplyPaging(IEnumerable<BaseItem> items, int? startIndex, int? limit)
+        var seasons = series.GetItemList(new InternalItemsQuery(user)
         {
-            // Start at
-            if (startIndex.HasValue)
-            {
-                items = items.Skip(startIndex.Value);
-            }
+            IsMissing = isMissing,
+            IsSpecialSeason = isSpecialSeason,
+            AdjacentTo = adjacentTo
+        });
 
-            // Return limit
-            if (limit.HasValue)
-            {
-                items = items.Take(limit.Value);
-            }
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+        var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user);
 
-            return items;
+        return new QueryResult<BaseItemDto>(returnItems);
+    }
+
+    /// <summary>
+    /// Applies the paging.
+    /// </summary>
+    /// <param name="items">The items.</param>
+    /// <param name="startIndex">The start index.</param>
+    /// <param name="limit">The limit.</param>
+    /// <returns>IEnumerable{BaseItem}.</returns>
+    private IEnumerable<BaseItem> ApplyPaging(IEnumerable<BaseItem> items, int? startIndex, int? limit)
+    {
+        // Start at
+        if (startIndex.HasValue)
+        {
+            items = items.Skip(startIndex.Value);
         }
+
+        // Return limit
+        if (limit.HasValue)
+        {
+            items = items.Take(limit.Value);
+        }
+
+        return items;
     }
 }

+ 261 - 262
Jellyfin.Api/Controllers/UniversalAudioController.cs

@@ -20,197 +20,164 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The universal audio controller.
+/// </summary>
+[Route("")]
+public class UniversalAudioController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILogger<UniversalAudioController> _logger;
+    private readonly MediaInfoHelper _mediaInfoHelper;
+    private readonly AudioHelper _audioHelper;
+    private readonly DynamicHlsHelper _dynamicHlsHelper;
+
     /// <summary>
-    /// The universal audio controller.
+    /// Initializes a new instance of the <see cref="UniversalAudioController"/> class.
     /// </summary>
-    [Route("")]
-    public class UniversalAudioController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{UniversalAudioController}"/> interface.</param>
+    /// <param name="mediaInfoHelper">Instance of <see cref="MediaInfoHelper"/>.</param>
+    /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param>
+    /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
+    public UniversalAudioController(
+        ILibraryManager libraryManager,
+        ILogger<UniversalAudioController> logger,
+        MediaInfoHelper mediaInfoHelper,
+        AudioHelper audioHelper,
+        DynamicHlsHelper dynamicHlsHelper)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly ILogger<UniversalAudioController> _logger;
-        private readonly MediaInfoHelper _mediaInfoHelper;
-        private readonly AudioHelper _audioHelper;
-        private readonly DynamicHlsHelper _dynamicHlsHelper;
+        _libraryManager = libraryManager;
+        _logger = logger;
+        _mediaInfoHelper = mediaInfoHelper;
+        _audioHelper = audioHelper;
+        _dynamicHlsHelper = dynamicHlsHelper;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="UniversalAudioController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{UniversalAudioController}"/> interface.</param>
-        /// <param name="mediaInfoHelper">Instance of <see cref="MediaInfoHelper"/>.</param>
-        /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param>
-        /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
-        public UniversalAudioController(
-            ILibraryManager libraryManager,
-            ILogger<UniversalAudioController> logger,
-            MediaInfoHelper mediaInfoHelper,
-            AudioHelper audioHelper,
-            DynamicHlsHelper dynamicHlsHelper)
-        {
-            _libraryManager = libraryManager;
-            _logger = logger;
-            _mediaInfoHelper = mediaInfoHelper;
-            _audioHelper = audioHelper;
-            _dynamicHlsHelper = dynamicHlsHelper;
-        }
+    /// <summary>
+    /// Gets an audio stream.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="container">Optional. The audio container.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="userId">Optional. The user id.</param>
+    /// <param name="audioCodec">Optional. The audio codec to transcode to.</param>
+    /// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param>
+    /// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param>
+    /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="transcodingContainer">Optional. The container to transcode to.</param>
+    /// <param name="transcodingProtocol">Optional. The transcoding protocol.</param>
+    /// <param name="maxAudioSampleRate">Optional. The maximum audio sample rate.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param>
+    /// <response code="200">Audio stream returned.</response>
+    /// <response code="302">Redirected to remote audio stream.</response>
+    /// <returns>A <see cref="Task"/> containing the audio file.</returns>
+    [HttpGet("Audio/{itemId}/universal")]
+    [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status302Found)]
+    [ProducesAudioFile]
+    public async Task<ActionResult> GetUniversalAudioStream(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] Guid? userId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] int? transcodingAudioChannels,
+        [FromQuery] int? maxStreamingBitrate,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] string? transcodingContainer,
+        [FromQuery] string? transcodingProtocol,
+        [FromQuery] int? maxAudioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] bool? enableRemoteMedia,
+        [FromQuery] bool breakOnNonKeyFrames = false,
+        [FromQuery] bool enableRedirection = true)
+    {
+        var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels);
 
-        /// <summary>
-        /// Gets an audio stream.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="container">Optional. The audio container.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="userId">Optional. The user id.</param>
-        /// <param name="audioCodec">Optional. The audio codec to transcode to.</param>
-        /// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param>
-        /// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param>
-        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="transcodingContainer">Optional. The container to transcode to.</param>
-        /// <param name="transcodingProtocol">Optional. The transcoding protocol.</param>
-        /// <param name="maxAudioSampleRate">Optional. The maximum audio sample rate.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param>
-        /// <response code="200">Audio stream returned.</response>
-        /// <response code="302">Redirected to remote audio stream.</response>
-        /// <returns>A <see cref="Task"/> containing the audio file.</returns>
-        [HttpGet("Audio/{itemId}/universal")]
-        [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status302Found)]
-        [ProducesAudioFile]
-        public async Task<ActionResult> GetUniversalAudioStream(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] int? transcodingAudioChannels,
-            [FromQuery] int? maxStreamingBitrate,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] string? transcodingContainer,
-            [FromQuery] string? transcodingProtocol,
-            [FromQuery] int? maxAudioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] bool? enableRemoteMedia,
-            [FromQuery] bool breakOnNonKeyFrames = false,
-            [FromQuery] bool enableRedirection = true)
+        if (!userId.HasValue || userId.Value.Equals(default))
         {
-            var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels);
-
-            if (!userId.HasValue || userId.Value.Equals(default))
-            {
-                userId = User.GetUserId();
-            }
-
-            _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile);
-
-            var info = await _mediaInfoHelper.GetPlaybackInfo(
-                    itemId,
-                    userId,
-                    mediaSourceId)
-                .ConfigureAwait(false);
+            userId = User.GetUserId();
+        }
 
-            // set device specific data
-            var item = _libraryManager.GetItemById(itemId);
+        _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile);
 
-            foreach (var sourceInfo in info.MediaSources)
-            {
-                _mediaInfoHelper.SetDeviceSpecificData(
-                    item,
-                    sourceInfo,
-                    deviceProfile,
-                    User,
-                    maxStreamingBitrate ?? deviceProfile.MaxStreamingBitrate,
-                    startTimeTicks ?? 0,
-                    mediaSourceId ?? string.Empty,
-                    null,
-                    null,
-                    maxAudioChannels,
-                    info.PlaySessionId!,
-                    userId ?? Guid.Empty,
-                    true,
-                    true,
-                    true,
-                    true,
-                    true,
-                    Request.HttpContext.GetNormalizedRemoteIp());
-            }
+        var info = await _mediaInfoHelper.GetPlaybackInfo(
+                itemId,
+                userId,
+                mediaSourceId)
+            .ConfigureAwait(false);
 
-            _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
+        // set device specific data
+        var item = _libraryManager.GetItemById(itemId);
 
-            foreach (var source in info.MediaSources)
-            {
-                _mediaInfoHelper.NormalizeMediaSourceContainer(source, deviceProfile, DlnaProfileType.Video);
-            }
+        foreach (var sourceInfo in info.MediaSources)
+        {
+            _mediaInfoHelper.SetDeviceSpecificData(
+                item,
+                sourceInfo,
+                deviceProfile,
+                User,
+                maxStreamingBitrate ?? deviceProfile.MaxStreamingBitrate,
+                startTimeTicks ?? 0,
+                mediaSourceId ?? string.Empty,
+                null,
+                null,
+                maxAudioChannels,
+                info.PlaySessionId!,
+                userId ?? Guid.Empty,
+                true,
+                true,
+                true,
+                true,
+                true,
+                Request.HttpContext.GetNormalizedRemoteIp());
+        }
 
-            var mediaSource = info.MediaSources[0];
-            if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http && enableRedirection && mediaSource.IsRemote && enableRemoteMedia.HasValue && enableRemoteMedia.Value)
-            {
-                return Redirect(mediaSource.Path);
-            }
+        _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
 
-            var isStatic = mediaSource.SupportsDirectStream;
-            if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
-            {
-                // hls segment container can only be mpegts or fmp4 per ffmpeg documentation
-                // ffmpeg option -> file extension
-                //        mpegts -> ts
-                //          fmp4 -> mp4
-                // TODO: remove this when we switch back to the segment muxer
-                var supportedHlsContainers = new[] { "ts", "mp4" };
+        foreach (var source in info.MediaSources)
+        {
+            _mediaInfoHelper.NormalizeMediaSourceContainer(source, deviceProfile, DlnaProfileType.Video);
+        }
 
-                var dynamicHlsRequestDto = new HlsAudioRequestDto
-                {
-                    Id = itemId,
-                    Container = ".m3u8",
-                    Static = isStatic,
-                    PlaySessionId = info.PlaySessionId,
-                    // fallback to mpegts if device reports some weird value unsupported by hls
-                    SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts",
-                    MediaSourceId = mediaSourceId,
-                    DeviceId = deviceId,
-                    AudioCodec = audioCodec,
-                    EnableAutoStreamCopy = true,
-                    AllowAudioStreamCopy = true,
-                    AllowVideoStreamCopy = true,
-                    BreakOnNonKeyFrames = breakOnNonKeyFrames,
-                    AudioSampleRate = maxAudioSampleRate,
-                    MaxAudioChannels = maxAudioChannels,
-                    MaxAudioBitDepth = maxAudioBitDepth,
-                    AudioBitRate = audioBitRate ?? maxStreamingBitrate,
-                    StartTimeTicks = startTimeTicks,
-                    SubtitleMethod = SubtitleDeliveryMethod.Hls,
-                    RequireAvc = false,
-                    DeInterlace = false,
-                    RequireNonAnamorphic = false,
-                    EnableMpegtsM2TsMode = false,
-                    TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(),
-                    Context = EncodingContext.Static,
-                    StreamOptions = new Dictionary<string, string>(),
-                    EnableAdaptiveBitrateStreaming = true
-                };
+        var mediaSource = info.MediaSources[0];
+        if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http && enableRedirection && mediaSource.IsRemote && enableRemoteMedia.HasValue && enableRemoteMedia.Value)
+        {
+            return Redirect(mediaSource.Path);
+        }
 
-                return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true)
-                    .ConfigureAwait(false);
-            }
+        var isStatic = mediaSource.SupportsDirectStream;
+        if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
+        {
+            // hls segment container can only be mpegts or fmp4 per ffmpeg documentation
+            // ffmpeg option -> file extension
+            //        mpegts -> ts
+            //          fmp4 -> mp4
+            // TODO: remove this when we switch back to the segment muxer
+            var supportedHlsContainers = new[] { "ts", "mp4" };
 
-            var audioStreamingDto = new StreamingRequestDto
+            var dynamicHlsRequestDto = new HlsAudioRequestDto
             {
                 Id = itemId,
-                Container = isStatic ? null : ("." + mediaSource.TranscodingContainer),
+                Container = ".m3u8",
                 Static = isStatic,
                 PlaySessionId = info.PlaySessionId,
+                // fallback to mpegts if device reports some weird value unsupported by hls
+                SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts",
                 MediaSourceId = mediaSourceId,
                 DeviceId = deviceId,
                 AudioCodec = audioCodec,
@@ -220,121 +187,153 @@ namespace Jellyfin.Api.Controllers
                 BreakOnNonKeyFrames = breakOnNonKeyFrames,
                 AudioSampleRate = maxAudioSampleRate,
                 MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = isStatic ? null : (audioBitRate ?? maxStreamingBitrate),
                 MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = maxAudioChannels,
-                CopyTimestamps = true,
+                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
                 StartTimeTicks = startTimeTicks,
-                SubtitleMethod = SubtitleDeliveryMethod.Embed,
+                SubtitleMethod = SubtitleDeliveryMethod.Hls,
+                RequireAvc = false,
+                DeInterlace = false,
+                RequireNonAnamorphic = false,
+                EnableMpegtsM2TsMode = false,
                 TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(),
-                Context = EncodingContext.Static
+                Context = EncodingContext.Static,
+                StreamOptions = new Dictionary<string, string>(),
+                EnableAdaptiveBitrateStreaming = true
             };
 
-            return await _audioHelper.GetAudioStream(TranscodingJobType.Progressive, audioStreamingDto).ConfigureAwait(false);
+            return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true)
+                .ConfigureAwait(false);
         }
 
-        private DeviceProfile GetDeviceProfile(
-            string[] containers,
-            string? transcodingContainer,
-            string? audioCodec,
-            string? transcodingProtocol,
-            bool? breakOnNonKeyFrames,
-            int? transcodingAudioChannels,
-            int? maxAudioSampleRate,
-            int? maxAudioBitDepth,
-            int? maxAudioChannels)
+        var audioStreamingDto = new StreamingRequestDto
         {
-            var deviceProfile = new DeviceProfile();
+            Id = itemId,
+            Container = isStatic ? null : ("." + mediaSource.TranscodingContainer),
+            Static = isStatic,
+            PlaySessionId = info.PlaySessionId,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = true,
+            AllowAudioStreamCopy = true,
+            AllowVideoStreamCopy = true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames,
+            AudioSampleRate = maxAudioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = isStatic ? null : (audioBitRate ?? maxStreamingBitrate),
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = maxAudioChannels,
+            CopyTimestamps = true,
+            StartTimeTicks = startTimeTicks,
+            SubtitleMethod = SubtitleDeliveryMethod.Embed,
+            TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(),
+            Context = EncodingContext.Static
+        };
 
-            int len = containers.Length;
-            var directPlayProfiles = new DirectPlayProfile[len];
-            for (int i = 0; i < len; i++)
-            {
-                var parts = containers[i].Split('|', StringSplitOptions.RemoveEmptyEntries);
+        return await _audioHelper.GetAudioStream(TranscodingJobType.Progressive, audioStreamingDto).ConfigureAwait(false);
+    }
 
-                var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1));
+    private DeviceProfile GetDeviceProfile(
+        string[] containers,
+        string? transcodingContainer,
+        string? audioCodec,
+        string? transcodingProtocol,
+        bool? breakOnNonKeyFrames,
+        int? transcodingAudioChannels,
+        int? maxAudioSampleRate,
+        int? maxAudioBitDepth,
+        int? maxAudioChannels)
+    {
+        var deviceProfile = new DeviceProfile();
 
-                directPlayProfiles[i] = new DirectPlayProfile
-                {
-                    Type = DlnaProfileType.Audio,
-                    Container = parts[0],
-                    AudioCodec = audioCodecs
-                };
-            }
+        int len = containers.Length;
+        var directPlayProfiles = new DirectPlayProfile[len];
+        for (int i = 0; i < len; i++)
+        {
+            var parts = containers[i].Split('|', StringSplitOptions.RemoveEmptyEntries);
 
-            deviceProfile.DirectPlayProfiles = directPlayProfiles;
+            var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1));
 
-            deviceProfile.TranscodingProfiles = new[]
+            directPlayProfiles[i] = new DirectPlayProfile
             {
-                new TranscodingProfile
-                {
-                    Type = DlnaProfileType.Audio,
-                    Context = EncodingContext.Streaming,
-                    Container = transcodingContainer ?? "mp3",
-                    AudioCodec = audioCodec ?? "mp3",
-                    Protocol = transcodingProtocol ?? "http",
-                    BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                    MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture)
-                }
+                Type = DlnaProfileType.Audio,
+                Container = parts[0],
+                AudioCodec = audioCodecs
             };
+        }
 
-            var codecProfiles = new List<CodecProfile>();
-            var conditions = new List<ProfileCondition>();
+        deviceProfile.DirectPlayProfiles = directPlayProfiles;
 
-            if (maxAudioSampleRate.HasValue)
+        deviceProfile.TranscodingProfiles = new[]
+        {
+            new TranscodingProfile
             {
-                // codec profile
-                conditions.Add(
-                    new ProfileCondition
-                    {
-                        Condition = ProfileConditionType.LessThanEqual,
-                        IsRequired = false,
-                        Property = ProfileConditionValue.AudioSampleRate,
-                        Value = maxAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)
-                    });
+                Type = DlnaProfileType.Audio,
+                Context = EncodingContext.Streaming,
+                Container = transcodingContainer ?? "mp3",
+                AudioCodec = audioCodec ?? "mp3",
+                Protocol = transcodingProtocol ?? "http",
+                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+                MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture)
             }
+        };
 
-            if (maxAudioBitDepth.HasValue)
-            {
-                // codec profile
-                conditions.Add(
-                    new ProfileCondition
-                    {
-                        Condition = ProfileConditionType.LessThanEqual,
-                        IsRequired = false,
-                        Property = ProfileConditionValue.AudioBitDepth,
-                        Value = maxAudioBitDepth.Value.ToString(CultureInfo.InvariantCulture)
-                    });
-            }
+        var codecProfiles = new List<CodecProfile>();
+        var conditions = new List<ProfileCondition>();
 
-            if (maxAudioChannels.HasValue)
-            {
-                // codec profile
-                conditions.Add(
-                    new ProfileCondition
-                    {
-                        Condition = ProfileConditionType.LessThanEqual,
-                        IsRequired = false,
-                        Property = ProfileConditionValue.AudioChannels,
-                        Value = maxAudioChannels.Value.ToString(CultureInfo.InvariantCulture)
-                    });
-            }
+        if (maxAudioSampleRate.HasValue)
+        {
+            // codec profile
+            conditions.Add(
+                new ProfileCondition
+                {
+                    Condition = ProfileConditionType.LessThanEqual,
+                    IsRequired = false,
+                    Property = ProfileConditionValue.AudioSampleRate,
+                    Value = maxAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)
+                });
+        }
 
-            if (conditions.Count > 0)
-            {
-                // codec profile
-                codecProfiles.Add(
-                    new CodecProfile
-                    {
-                        Type = CodecType.Audio,
-                        Container = string.Join(',', containers),
-                        Conditions = conditions.ToArray()
-                    });
-            }
+        if (maxAudioBitDepth.HasValue)
+        {
+            // codec profile
+            conditions.Add(
+                new ProfileCondition
+                {
+                    Condition = ProfileConditionType.LessThanEqual,
+                    IsRequired = false,
+                    Property = ProfileConditionValue.AudioBitDepth,
+                    Value = maxAudioBitDepth.Value.ToString(CultureInfo.InvariantCulture)
+                });
+        }
 
-            deviceProfile.CodecProfiles = codecProfiles.ToArray();
+        if (maxAudioChannels.HasValue)
+        {
+            // codec profile
+            conditions.Add(
+                new ProfileCondition
+                {
+                    Condition = ProfileConditionType.LessThanEqual,
+                    IsRequired = false,
+                    Property = ProfileConditionValue.AudioChannels,
+                    Value = maxAudioChannels.Value.ToString(CultureInfo.InvariantCulture)
+                });
+        }
 
-            return deviceProfile;
+        if (conditions.Count > 0)
+        {
+            // codec profile
+            codecProfiles.Add(
+                new CodecProfile
+                {
+                    Type = CodecType.Audio,
+                    Container = string.Join(',', containers),
+                    Conditions = conditions.ToArray()
+                });
         }
+
+        deviceProfile.CodecProfiles = codecProfiles.ToArray();
+
+        return deviceProfile;
     }
 }

+ 461 - 462
Jellyfin.Api/Controllers/UserController.cs

@@ -25,564 +25,563 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// User controller.
+/// </summary>
+[Route("Users")]
+public class UserController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly ISessionManager _sessionManager;
+    private readonly INetworkManager _networkManager;
+    private readonly IDeviceManager _deviceManager;
+    private readonly IAuthorizationContext _authContext;
+    private readonly IServerConfigurationManager _config;
+    private readonly ILogger _logger;
+    private readonly IQuickConnect _quickConnectManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="UserController"/> class.
+    /// </summary>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
+    /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+    /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+    /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+    /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+    /// <param name="quickConnectManager">Instance of the <see cref="IQuickConnect"/> interface.</param>
+    public UserController(
+        IUserManager userManager,
+        ISessionManager sessionManager,
+        INetworkManager networkManager,
+        IDeviceManager deviceManager,
+        IAuthorizationContext authContext,
+        IServerConfigurationManager config,
+        ILogger<UserController> logger,
+        IQuickConnect quickConnectManager)
+    {
+        _userManager = userManager;
+        _sessionManager = sessionManager;
+        _networkManager = networkManager;
+        _deviceManager = deviceManager;
+        _authContext = authContext;
+        _config = config;
+        _logger = logger;
+        _quickConnectManager = quickConnectManager;
+    }
+
+    /// <summary>
+    /// Gets a list of users.
+    /// </summary>
+    /// <param name="isHidden">Optional filter by IsHidden=true or false.</param>
+    /// <param name="isDisabled">Optional filter by IsDisabled=true or false.</param>
+    /// <response code="200">Users returned.</response>
+    /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the users.</returns>
+    [HttpGet]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<UserDto>> GetUsers(
+        [FromQuery] bool? isHidden,
+        [FromQuery] bool? isDisabled)
+    {
+        var users = Get(isHidden, isDisabled, false, false);
+        return Ok(users);
+    }
+
     /// <summary>
-    /// User controller.
+    /// Gets a list of publicly visible users for display on a login screen.
     /// </summary>
-    [Route("Users")]
-    public class UserController : BaseJellyfinApiController
+    /// <response code="200">Public users returned.</response>
+    /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns>
+    [HttpGet("Public")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<UserDto>> GetPublicUsers()
     {
-        private readonly IUserManager _userManager;
-        private readonly ISessionManager _sessionManager;
-        private readonly INetworkManager _networkManager;
-        private readonly IDeviceManager _deviceManager;
-        private readonly IAuthorizationContext _authContext;
-        private readonly IServerConfigurationManager _config;
-        private readonly ILogger _logger;
-        private readonly IQuickConnect _quickConnectManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="UserController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
-        /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
-        /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
-        /// <param name="quickConnectManager">Instance of the <see cref="IQuickConnect"/> interface.</param>
-        public UserController(
-            IUserManager userManager,
-            ISessionManager sessionManager,
-            INetworkManager networkManager,
-            IDeviceManager deviceManager,
-            IAuthorizationContext authContext,
-            IServerConfigurationManager config,
-            ILogger<UserController> logger,
-            IQuickConnect quickConnectManager)
+        // If the startup wizard hasn't been completed then just return all users
+        if (!_config.Configuration.IsStartupWizardCompleted)
         {
-            _userManager = userManager;
-            _sessionManager = sessionManager;
-            _networkManager = networkManager;
-            _deviceManager = deviceManager;
-            _authContext = authContext;
-            _config = config;
-            _logger = logger;
-            _quickConnectManager = quickConnectManager;
+            return Ok(Get(false, false, false, false));
         }
 
-        /// <summary>
-        /// Gets a list of users.
-        /// </summary>
-        /// <param name="isHidden">Optional filter by IsHidden=true or false.</param>
-        /// <param name="isDisabled">Optional filter by IsDisabled=true or false.</param>
-        /// <response code="200">Users returned.</response>
-        /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the users.</returns>
-        [HttpGet]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<UserDto>> GetUsers(
-            [FromQuery] bool? isHidden,
-            [FromQuery] bool? isDisabled)
+        return Ok(Get(false, false, true, true));
+    }
+
+    /// <summary>
+    /// Gets a user by Id.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <response code="200">User returned.</response>
+    /// <response code="404">User not found.</response>
+    /// <returns>An <see cref="UserDto"/> with information about the user or a <see cref="NotFoundResult"/> if the user was not found.</returns>
+    [HttpGet("{userId}")]
+    [Authorize(Policy = Policies.IgnoreParentalControl)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<UserDto> GetUserById([FromRoute, Required] Guid userId)
+    {
+        var user = _userManager.GetUserById(userId);
+
+        if (user is null)
         {
-            var users = Get(isHidden, isDisabled, false, false);
-            return Ok(users);
+            return NotFound("User not found");
         }
 
-        /// <summary>
-        /// Gets a list of publicly visible users for display on a login screen.
-        /// </summary>
-        /// <response code="200">Public users returned.</response>
-        /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns>
-        [HttpGet("Public")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<UserDto>> GetPublicUsers()
-        {
-            // If the startup wizard hasn't been completed then just return all users
-            if (!_config.Configuration.IsStartupWizardCompleted)
-            {
-                return Ok(Get(false, false, false, false));
-            }
+        var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp().ToString());
+        return result;
+    }
+
+    /// <summary>
+    /// Deletes a user.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <response code="204">User deleted.</response>
+    /// <response code="404">User not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="NotFoundResult"/> if the user was not found.</returns>
+    [HttpDelete("{userId}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId)
+    {
+        var user = _userManager.GetUserById(userId);
+        await _sessionManager.RevokeUserTokens(user.Id, null).ConfigureAwait(false);
+        await _userManager.DeleteUserAsync(userId).ConfigureAwait(false);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Authenticates a user.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="pw">The password as plain text.</param>
+    /// <response code="200">User authenticated.</response>
+    /// <response code="403">Sha1-hashed password only is not allowed.</response>
+    /// <response code="404">User not found.</response>
+    /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationResult"/>.</returns>
+    [HttpPost("{userId}/Authenticate")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Obsolete("Authenticate with username instead")]
+    public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
+        [FromRoute, Required] Guid userId,
+        [FromQuery, Required] string pw)
+    {
+        var user = _userManager.GetUserById(userId);
 
-            return Ok(Get(false, false, true, true));
+        if (user is null)
+        {
+            return NotFound("User not found");
         }
 
-        /// <summary>
-        /// Gets a user by Id.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <response code="200">User returned.</response>
-        /// <response code="404">User not found.</response>
-        /// <returns>An <see cref="UserDto"/> with information about the user or a <see cref="NotFoundResult"/> if the user was not found.</returns>
-        [HttpGet("{userId}")]
-        [Authorize(Policy = Policies.IgnoreParentalControl)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<UserDto> GetUserById([FromRoute, Required] Guid userId)
+        AuthenticateUserByName request = new AuthenticateUserByName
         {
-            var user = _userManager.GetUserById(userId);
+            Username = user.Username,
+            Pw = pw
+        };
+        return await AuthenticateUserByName(request).ConfigureAwait(false);
+    }
+
+    /// <summary>
+    /// Authenticates a user by name.
+    /// </summary>
+    /// <param name="request">The <see cref="AuthenticateUserByName"/> request.</param>
+    /// <response code="200">User authenticated.</response>
+    /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
+    [HttpPost("AuthenticateByName")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request)
+    {
+        var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
 
-            if (user is null)
+        try
+        {
+            var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest
             {
-                return NotFound("User not found");
-            }
+                App = auth.Client,
+                AppVersion = auth.Version,
+                DeviceId = auth.DeviceId,
+                DeviceName = auth.Device,
+                Password = request.Pw,
+                RemoteEndPoint = HttpContext.GetNormalizedRemoteIp().ToString(),
+                Username = request.Username
+            }).ConfigureAwait(false);
 
-            var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp().ToString());
             return result;
         }
-
-        /// <summary>
-        /// Deletes a user.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <response code="204">User deleted.</response>
-        /// <response code="404">User not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="NotFoundResult"/> if the user was not found.</returns>
-        [HttpDelete("{userId}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId)
+        catch (SecurityException e)
         {
-            var user = _userManager.GetUserById(userId);
-            await _sessionManager.RevokeUserTokens(user.Id, null).ConfigureAwait(false);
-            await _userManager.DeleteUserAsync(userId).ConfigureAwait(false);
-            return NoContent();
+            // rethrow adding IP address to message
+            throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e);
         }
+    }
 
-        /// <summary>
-        /// Authenticates a user.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="pw">The password as plain text.</param>
-        /// <response code="200">User authenticated.</response>
-        /// <response code="403">Sha1-hashed password only is not allowed.</response>
-        /// <response code="404">User not found.</response>
-        /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationResult"/>.</returns>
-        [HttpPost("{userId}/Authenticate")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [Obsolete("Authenticate with username instead")]
-        public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
-            [FromRoute, Required] Guid userId,
-            [FromQuery, Required] string pw)
+    /// <summary>
+    /// Authenticates a user with quick connect.
+    /// </summary>
+    /// <param name="request">The <see cref="QuickConnectDto"/> request.</param>
+    /// <response code="200">User authenticated.</response>
+    /// <response code="400">Missing token.</response>
+    /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
+    [HttpPost("AuthenticateWithQuickConnect")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
+    {
+        try
         {
-            var user = _userManager.GetUserById(userId);
-
-            if (user is null)
-            {
-                return NotFound("User not found");
-            }
-
-            AuthenticateUserByName request = new AuthenticateUserByName
-            {
-                Username = user.Username,
-                Pw = pw
-            };
-            return await AuthenticateUserByName(request).ConfigureAwait(false);
+            return _quickConnectManager.GetAuthorizedRequest(request.Secret);
         }
-
-        /// <summary>
-        /// Authenticates a user by name.
-        /// </summary>
-        /// <param name="request">The <see cref="AuthenticateUserByName"/> request.</param>
-        /// <response code="200">User authenticated.</response>
-        /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
-        [HttpPost("AuthenticateByName")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request)
+        catch (SecurityException e)
         {
-            var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
+            // rethrow adding IP address to message
+            throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e);
+        }
+    }
 
-            try
-            {
-                var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest
-                {
-                    App = auth.Client,
-                    AppVersion = auth.Version,
-                    DeviceId = auth.DeviceId,
-                    DeviceName = auth.Device,
-                    Password = request.Pw,
-                    RemoteEndPoint = HttpContext.GetNormalizedRemoteIp().ToString(),
-                    Username = request.Username
-                }).ConfigureAwait(false);
-
-                return result;
-            }
-            catch (SecurityException e)
-            {
-                // rethrow adding IP address to message
-                throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e);
-            }
+    /// <summary>
+    /// Updates a user's password.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param>
+    /// <response code="204">Password successfully reset.</response>
+    /// <response code="403">User is not allowed to update the password.</response>
+    /// <response code="404">User not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
+    [HttpPost("{userId}/Password")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> UpdateUserPassword(
+        [FromRoute, Required] Guid userId,
+        [FromBody, Required] UpdateUserPassword request)
+    {
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password.");
         }
 
-        /// <summary>
-        /// Authenticates a user with quick connect.
-        /// </summary>
-        /// <param name="request">The <see cref="QuickConnectDto"/> request.</param>
-        /// <response code="200">User authenticated.</response>
-        /// <response code="400">Missing token.</response>
-        /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
-        [HttpPost("AuthenticateWithQuickConnect")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
+        var user = _userManager.GetUserById(userId);
+
+        if (user is null)
         {
-            try
-            {
-                return _quickConnectManager.GetAuthorizedRequest(request.Secret);
-            }
-            catch (SecurityException e)
-            {
-                // rethrow adding IP address to message
-                throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e);
-            }
+            return NotFound("User not found");
         }
 
-        /// <summary>
-        /// Updates a user's password.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param>
-        /// <response code="204">Password successfully reset.</response>
-        /// <response code="403">User is not allowed to update the password.</response>
-        /// <response code="404">User not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
-        [HttpPost("{userId}/Password")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> UpdateUserPassword(
-            [FromRoute, Required] Guid userId,
-            [FromBody, Required] UpdateUserPassword request)
+        if (request.ResetPassword)
         {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+            await _userManager.ResetPassword(user).ConfigureAwait(false);
+        }
+        else
+        {
+            if (!User.IsInRole(UserRoles.Administrator) || User.GetUserId().Equals(userId))
             {
-                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password.");
+                var success = await _userManager.AuthenticateUser(
+                    user.Username,
+                    request.CurrentPw,
+                    request.CurrentPw,
+                    HttpContext.GetNormalizedRemoteIp().ToString(),
+                    false).ConfigureAwait(false);
+
+                if (success is null)
+                {
+                    return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered.");
+                }
             }
 
-            var user = _userManager.GetUserById(userId);
+            await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false);
 
-            if (user is null)
-            {
-                return NotFound("User not found");
-            }
+            var currentToken = User.GetToken();
 
-            if (request.ResetPassword)
-            {
-                await _userManager.ResetPassword(user).ConfigureAwait(false);
-            }
-            else
-            {
-                if (!User.IsInRole(UserRoles.Administrator) || User.GetUserId().Equals(userId))
-                {
-                    var success = await _userManager.AuthenticateUser(
-                        user.Username,
-                        request.CurrentPw,
-                        request.CurrentPw,
-                        HttpContext.GetNormalizedRemoteIp().ToString(),
-                        false).ConfigureAwait(false);
-
-                    if (success is null)
-                    {
-                        return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered.");
-                    }
-                }
+            await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false);
+        }
 
-                await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false);
+        return NoContent();
+    }
 
-                var currentToken = User.GetToken();
+    /// <summary>
+    /// Updates a user's easy password.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param>
+    /// <response code="204">Password successfully reset.</response>
+    /// <response code="403">User is not allowed to update the password.</response>
+    /// <response code="404">User not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
+    [HttpPost("{userId}/EasyPassword")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> UpdateUserEasyPassword(
+        [FromRoute, Required] Guid userId,
+        [FromBody, Required] UpdateUserEasyPassword request)
+    {
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the easy password.");
+        }
 
-                await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false);
-            }
+        var user = _userManager.GetUserById(userId);
 
-            return NoContent();
+        if (user is null)
+        {
+            return NotFound("User not found");
         }
 
-        /// <summary>
-        /// Updates a user's easy password.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param>
-        /// <response code="204">Password successfully reset.</response>
-        /// <response code="403">User is not allowed to update the password.</response>
-        /// <response code="404">User not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
-        [HttpPost("{userId}/EasyPassword")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> UpdateUserEasyPassword(
-            [FromRoute, Required] Guid userId,
-            [FromBody, Required] UpdateUserEasyPassword request)
+        if (request.ResetPassword)
         {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
-            {
-                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the easy password.");
-            }
+            await _userManager.ResetEasyPassword(user).ConfigureAwait(false);
+        }
+        else
+        {
+            await _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword).ConfigureAwait(false);
+        }
 
-            var user = _userManager.GetUserById(userId);
+        return NoContent();
+    }
 
-            if (user is null)
-            {
-                return NotFound("User not found");
-            }
+    /// <summary>
+    /// Updates a user.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="updateUser">The updated user model.</param>
+    /// <response code="204">User updated.</response>
+    /// <response code="400">User information was not supplied.</response>
+    /// <response code="403">User update forbidden.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns>
+    [HttpPost("{userId}")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public async Task<ActionResult> UpdateUser(
+        [FromRoute, Required] Guid userId,
+        [FromBody, Required] UserDto updateUser)
+    {
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed.");
+        }
 
-            if (request.ResetPassword)
-            {
-                await _userManager.ResetEasyPassword(user).ConfigureAwait(false);
-            }
-            else
-            {
-                await _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword).ConfigureAwait(false);
-            }
+        var user = _userManager.GetUserById(userId);
 
-            return NoContent();
+        if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
+        {
+            await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
         }
 
-        /// <summary>
-        /// Updates a user.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="updateUser">The updated user model.</param>
-        /// <response code="204">User updated.</response>
-        /// <response code="400">User information was not supplied.</response>
-        /// <response code="403">User update forbidden.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns>
-        [HttpPost("{userId}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status400BadRequest)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        public async Task<ActionResult> UpdateUser(
-            [FromRoute, Required] Guid userId,
-            [FromBody, Required] UserDto updateUser)
-        {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
-            {
-                return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed.");
-            }
+        await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false);
 
-            var user = _userManager.GetUserById(userId);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Updates a user policy.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="newPolicy">The new user policy.</param>
+    /// <response code="204">User policy updated.</response>
+    /// <response code="400">User policy was not supplied.</response>
+    /// <response code="403">User policy update forbidden.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure..</returns>
+    [HttpPost("{userId}/Policy")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public async Task<ActionResult> UpdateUserPolicy(
+        [FromRoute, Required] Guid userId,
+        [FromBody, Required] UserPolicy newPolicy)
+    {
+        var user = _userManager.GetUserById(userId);
 
-            if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
+        // If removing admin access
+        if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))
+        {
+            if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
             {
-                await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
+                return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access.");
             }
-
-            await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false);
-
-            return NoContent();
         }
 
-        /// <summary>
-        /// Updates a user policy.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="newPolicy">The new user policy.</param>
-        /// <response code="204">User policy updated.</response>
-        /// <response code="400">User policy was not supplied.</response>
-        /// <response code="403">User policy update forbidden.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure..</returns>
-        [HttpPost("{userId}/Policy")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status400BadRequest)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        public async Task<ActionResult> UpdateUserPolicy(
-            [FromRoute, Required] Guid userId,
-            [FromBody, Required] UserPolicy newPolicy)
+        // If disabling
+        if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator))
         {
-            var user = _userManager.GetUserById(userId);
-
-            // If removing admin access
-            if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))
-            {
-                if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
-                {
-                    return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access.");
-                }
-            }
+            return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled.");
+        }
 
-            // If disabling
-            if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator))
+        // If disabling
+        if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
+        {
+            if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
             {
-                return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled.");
+                return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system.");
             }
 
-            // If disabling
-            if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
-            {
-                if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
-                {
-                    return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system.");
-                }
+            var currentToken = User.GetToken();
+            await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false);
+        }
 
-                var currentToken = User.GetToken();
-                await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false);
-            }
+        await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false);
 
-            await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            return NoContent();
+    /// <summary>
+    /// Updates a user configuration.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="userConfig">The new user configuration.</param>
+    /// <response code="204">User configuration updated.</response>
+    /// <response code="403">User configuration update forbidden.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("{userId}/Configuration")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public async Task<ActionResult> UpdateUserConfiguration(
+        [FromRoute, Required] Guid userId,
+        [FromBody, Required] UserConfiguration userConfig)
+    {
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed");
         }
 
-        /// <summary>
-        /// Updates a user configuration.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="userConfig">The new user configuration.</param>
-        /// <response code="204">User configuration updated.</response>
-        /// <response code="403">User configuration update forbidden.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("{userId}/Configuration")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        public async Task<ActionResult> UpdateUserConfiguration(
-            [FromRoute, Required] Guid userId,
-            [FromBody, Required] UserConfiguration userConfig)
-        {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
-            {
-                return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed");
-            }
+        await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false);
 
-            await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            return NoContent();
-        }
+    /// <summary>
+    /// Creates a user.
+    /// </summary>
+    /// <param name="request">The create user by name request body.</param>
+    /// <response code="200">User created.</response>
+    /// <returns>An <see cref="UserDto"/> of the new user.</returns>
+    [HttpPost("New")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<UserDto>> CreateUserByName([FromBody, Required] CreateUserByName request)
+    {
+        var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false);
 
-        /// <summary>
-        /// Creates a user.
-        /// </summary>
-        /// <param name="request">The create user by name request body.</param>
-        /// <response code="200">User created.</response>
-        /// <returns>An <see cref="UserDto"/> of the new user.</returns>
-        [HttpPost("New")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<UserDto>> CreateUserByName([FromBody, Required] CreateUserByName request)
+        // no need to authenticate password for new user
+        if (request.Password is not null)
         {
-            var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false);
+            await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false);
+        }
 
-            // no need to authenticate password for new user
-            if (request.Password is not null)
-            {
-                await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false);
-            }
+        var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString());
 
-            var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString());
+        return result;
+    }
 
-            return result;
-        }
+    /// <summary>
+    /// Initiates the forgot password process for a local user.
+    /// </summary>
+    /// <param name="forgotPasswordRequest">The forgot password request containing the entered username.</param>
+    /// <response code="200">Password reset process started.</response>
+    /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns>
+    [HttpPost("ForgotPassword")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest)
+    {
+        var ip = HttpContext.GetNormalizedRemoteIp();
+        var isLocal = HttpContext.IsLocal()
+                      || _networkManager.IsInLocalNetwork(ip);
 
-        /// <summary>
-        /// Initiates the forgot password process for a local user.
-        /// </summary>
-        /// <param name="forgotPasswordRequest">The forgot password request containing the entered username.</param>
-        /// <response code="200">Password reset process started.</response>
-        /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns>
-        [HttpPost("ForgotPassword")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest)
+        if (isLocal)
         {
-            var ip = HttpContext.GetNormalizedRemoteIp();
-            var isLocal = HttpContext.IsLocal()
-                          || _networkManager.IsInLocalNetwork(ip);
+            _logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip);
+        }
 
-            if (isLocal)
-            {
-                _logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip);
-            }
+        var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false);
 
-            var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false);
+        return result;
+    }
 
-            return result;
-        }
+    /// <summary>
+    /// Redeems a forgot password pin.
+    /// </summary>
+    /// <param name="forgotPasswordPinRequest">The forgot password pin request containing the entered pin.</param>
+    /// <response code="200">Pin reset process started.</response>
+    /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns>
+    [HttpPost("ForgotPassword/Pin")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest)
+    {
+        var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false);
+        return result;
+    }
 
-        /// <summary>
-        /// Redeems a forgot password pin.
-        /// </summary>
-        /// <param name="forgotPasswordPinRequest">The forgot password pin request containing the entered pin.</param>
-        /// <response code="200">Pin reset process started.</response>
-        /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns>
-        [HttpPost("ForgotPassword/Pin")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest)
+    /// <summary>
+    /// Gets the user based on auth token.
+    /// </summary>
+    /// <response code="200">User returned.</response>
+    /// <response code="400">Token is not owned by a user.</response>
+    /// <returns>A <see cref="UserDto"/> for the authenticated user.</returns>
+    [HttpGet("Me")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
+    public ActionResult<UserDto> GetCurrentUser()
+    {
+        var userId = User.GetUserId();
+        if (userId.Equals(default))
         {
-            var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false);
-            return result;
+            return BadRequest();
         }
 
-        /// <summary>
-        /// Gets the user based on auth token.
-        /// </summary>
-        /// <response code="200">User returned.</response>
-        /// <response code="400">Token is not owned by a user.</response>
-        /// <returns>A <see cref="UserDto"/> for the authenticated user.</returns>
-        [HttpGet("Me")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status400BadRequest)]
-        public ActionResult<UserDto> GetCurrentUser()
+        var user = _userManager.GetUserById(userId);
+        if (user is null)
         {
-            var userId = User.GetUserId();
-            if (userId.Equals(default))
-            {
-                return BadRequest();
-            }
+            return BadRequest();
+        }
 
-            var user = _userManager.GetUserById(userId);
-            if (user is null)
-            {
-                return BadRequest();
-            }
+        return _userManager.GetUserDto(user);
+    }
 
-            return _userManager.GetUserDto(user);
-        }
+    private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
+    {
+        var users = _userManager.Users;
 
-        private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
+        if (isDisabled.HasValue)
         {
-            var users = _userManager.Users;
+            users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value);
+        }
 
-            if (isDisabled.HasValue)
-            {
-                users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value);
-            }
+        if (isHidden.HasValue)
+        {
+            users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value);
+        }
 
-            if (isHidden.HasValue)
-            {
-                users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value);
-            }
+        if (filterByDevice)
+        {
+            var deviceId = User.GetDeviceId();
 
-            if (filterByDevice)
+            if (!string.IsNullOrWhiteSpace(deviceId))
             {
-                var deviceId = User.GetDeviceId();
-
-                if (!string.IsNullOrWhiteSpace(deviceId))
-                {
-                    users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId));
-                }
+                users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId));
             }
+        }
 
-            if (filterByNetwork)
+        if (filterByNetwork)
+        {
+            if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp()))
             {
-                if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp()))
-                {
-                    users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess));
-                }
+                users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess));
             }
+        }
 
-            var result = users
-                .OrderBy(u => u.Username)
-                .Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIp().ToString()));
+        var result = users
+            .OrderBy(u => u.Username)
+            .Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIp().ToString()));
 
-            return result;
-        }
+        return result;
     }
 }

+ 337 - 338
Jellyfin.Api/Controllers/UserLibraryController.cs

@@ -23,406 +23,405 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// User library controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class UserLibraryController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly IUserDataManager _userDataRepository;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IDtoService _dtoService;
+    private readonly IUserViewManager _userViewManager;
+    private readonly IFileSystem _fileSystem;
+    private readonly ILyricManager _lyricManager;
+
     /// <summary>
-    /// User library controller.
+    /// Initializes a new instance of the <see cref="UserLibraryController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class UserLibraryController : BaseJellyfinApiController
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
+    public UserLibraryController(
+        IUserManager userManager,
+        IUserDataManager userDataRepository,
+        ILibraryManager libraryManager,
+        IDtoService dtoService,
+        IUserViewManager userViewManager,
+        IFileSystem fileSystem,
+        ILyricManager lyricManager)
     {
-        private readonly IUserManager _userManager;
-        private readonly IUserDataManager _userDataRepository;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-        private readonly IUserViewManager _userViewManager;
-        private readonly IFileSystem _fileSystem;
-        private readonly ILyricManager _lyricManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="UserLibraryController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
-        public UserLibraryController(
-            IUserManager userManager,
-            IUserDataManager userDataRepository,
-            ILibraryManager libraryManager,
-            IDtoService dtoService,
-            IUserViewManager userViewManager,
-            IFileSystem fileSystem,
-            ILyricManager lyricManager)
-        {
-            _userManager = userManager;
-            _userDataRepository = userDataRepository;
-            _libraryManager = libraryManager;
-            _dtoService = dtoService;
-            _userViewManager = userViewManager;
-            _fileSystem = fileSystem;
-            _lyricManager = lyricManager;
-        }
-
-        /// <summary>
-        /// Gets an item from a user's library.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">Item returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the d item.</returns>
-        [HttpGet("Users/{userId}/Items/{itemId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
-        {
-            var user = _userManager.GetUserById(userId);
+        _userManager = userManager;
+        _userDataRepository = userDataRepository;
+        _libraryManager = libraryManager;
+        _dtoService = dtoService;
+        _userViewManager = userViewManager;
+        _fileSystem = fileSystem;
+        _lyricManager = lyricManager;
+    }
 
-            var item = itemId.Equals(default)
-                ? _libraryManager.GetUserRootFolder()
-                : _libraryManager.GetItemById(itemId);
+    /// <summary>
+    /// Gets an item from a user's library.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Item returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the d item.</returns>
+    [HttpGet("Users/{userId}/Items/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    {
+        var user = _userManager.GetUserById(userId);
 
-            await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
+        var item = itemId.Equals(default)
+            ? _libraryManager.GetUserRootFolder()
+            : _libraryManager.GetItemById(itemId);
 
-            var dtoOptions = new DtoOptions().AddClientFields(User);
+        await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
 
-            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
-        }
+        var dtoOptions = new DtoOptions().AddClientFields(User);
 
-        /// <summary>
-        /// Gets the root folder from a user's library.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <response code="200">Root folder returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns>
-        [HttpGet("Users/{userId}/Items/Root")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<BaseItemDto> GetRootFolder([FromRoute, Required] Guid userId)
-        {
-            var user = _userManager.GetUserById(userId);
-            var item = _libraryManager.GetUserRootFolder();
-            var dtoOptions = new DtoOptions().AddClientFields(User);
-            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
-        }
+        return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+    }
 
-        /// <summary>
-        /// Gets intros to play before the main media item plays.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">Intros returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns>
-        [HttpGet("Users/{userId}/Items/{itemId}/Intros")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
-        {
-            var user = _userManager.GetUserById(userId);
+    /// <summary>
+    /// Gets the root folder from a user's library.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <response code="200">Root folder returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns>
+    [HttpGet("Users/{userId}/Items/Root")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<BaseItemDto> GetRootFolder([FromRoute, Required] Guid userId)
+    {
+        var user = _userManager.GetUserById(userId);
+        var item = _libraryManager.GetUserRootFolder();
+        var dtoOptions = new DtoOptions().AddClientFields(User);
+        return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+    }
 
-            var item = itemId.Equals(default)
-                ? _libraryManager.GetUserRootFolder()
-                : _libraryManager.GetItemById(itemId);
+    /// <summary>
+    /// Gets intros to play before the main media item plays.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Intros returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns>
+    [HttpGet("Users/{userId}/Items/{itemId}/Intros")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    {
+        var user = _userManager.GetUserById(userId);
 
-            var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false);
-            var dtoOptions = new DtoOptions().AddClientFields(User);
-            var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray();
+        var item = itemId.Equals(default)
+            ? _libraryManager.GetUserRootFolder()
+            : _libraryManager.GetItemById(itemId);
 
-            return new QueryResult<BaseItemDto>(dtos);
-        }
+        var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false);
+        var dtoOptions = new DtoOptions().AddClientFields(User);
+        var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray();
 
-        /// <summary>
-        /// Marks an item as a favorite.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">Item marked as favorite.</response>
-        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
-        [HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
-        {
-            return MarkFavorite(userId, itemId, true);
-        }
+        return new QueryResult<BaseItemDto>(dtos);
+    }
 
-        /// <summary>
-        /// Unmarks item as a favorite.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">Item unmarked as favorite.</response>
-        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
-        [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
-        {
-            return MarkFavorite(userId, itemId, false);
-        }
+    /// <summary>
+    /// Marks an item as a favorite.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Item marked as favorite.</response>
+    /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+    [HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    {
+        return MarkFavorite(userId, itemId, true);
+    }
 
-        /// <summary>
-        /// Deletes a user's saved personal rating for an item.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">Personal rating removed.</response>
-        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
-        [HttpDelete("Users/{userId}/Items/{itemId}/Rating")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
-        {
-            return UpdateUserItemRatingInternal(userId, itemId, null);
-        }
+    /// <summary>
+    /// Unmarks item as a favorite.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Item unmarked as favorite.</response>
+    /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+    [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    {
+        return MarkFavorite(userId, itemId, false);
+    }
 
-        /// <summary>
-        /// Updates a user's rating for an item.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param>
-        /// <response code="200">Item rating updated.</response>
-        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
-        [HttpPost("Users/{userId}/Items/{itemId}/Rating")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes)
-        {
-            return UpdateUserItemRatingInternal(userId, itemId, likes);
-        }
+    /// <summary>
+    /// Deletes a user's saved personal rating for an item.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Personal rating removed.</response>
+    /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+    [HttpDelete("Users/{userId}/Items/{itemId}/Rating")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    {
+        return UpdateUserItemRatingInternal(userId, itemId, null);
+    }
 
-        /// <summary>
-        /// Gets local trailers for an item.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response>
-        /// <returns>The items local trailers.</returns>
-        [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
-        {
-            var user = _userManager.GetUserById(userId);
+    /// <summary>
+    /// Updates a user's rating for an item.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param>
+    /// <response code="200">Item rating updated.</response>
+    /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+    [HttpPost("Users/{userId}/Items/{itemId}/Rating")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes)
+    {
+        return UpdateUserItemRatingInternal(userId, itemId, likes);
+    }
 
-            var item = itemId.Equals(default)
-                ? _libraryManager.GetUserRootFolder()
-                : _libraryManager.GetItemById(itemId);
+    /// <summary>
+    /// Gets local trailers for an item.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response>
+    /// <returns>The items local trailers.</returns>
+    [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    {
+        var user = _userManager.GetUserById(userId);
 
-            var dtoOptions = new DtoOptions().AddClientFields(User);
+        var item = itemId.Equals(default)
+            ? _libraryManager.GetUserRootFolder()
+            : _libraryManager.GetItemById(itemId);
 
-            if (item is IHasTrailers hasTrailers)
-            {
-                var trailers = hasTrailers.LocalTrailers;
-                return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable());
-            }
+        var dtoOptions = new DtoOptions().AddClientFields(User);
 
-            return Ok(item.GetExtras()
-                .Where(e => e.ExtraType == ExtraType.Trailer)
-                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
+        if (item is IHasTrailers hasTrailers)
+        {
+            var trailers = hasTrailers.LocalTrailers;
+            return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable());
         }
 
-        /// <summary>
-        /// Gets special features for an item.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">Special features returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the special features.</returns>
-        [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
-        {
-            var user = _userManager.GetUserById(userId);
+        return Ok(item.GetExtras()
+            .Where(e => e.ExtraType == ExtraType.Trailer)
+            .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
+    }
 
-            var item = itemId.Equals(default)
-                ? _libraryManager.GetUserRootFolder()
-                : _libraryManager.GetItemById(itemId);
+    /// <summary>
+    /// Gets special features for an item.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Special features returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the special features.</returns>
+    [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    {
+        var user = _userManager.GetUserById(userId);
 
-            var dtoOptions = new DtoOptions().AddClientFields(User);
+        var item = itemId.Equals(default)
+            ? _libraryManager.GetUserRootFolder()
+            : _libraryManager.GetItemById(itemId);
 
-            return Ok(item
-                .GetExtras()
-                .Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value))
-                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
-        }
+        var dtoOptions = new DtoOptions().AddClientFields(User);
 
-        /// <summary>
-        /// Gets latest media.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="isPlayed">Filter by items that are played, or not.</param>
-        /// <param name="enableImages">Optional. include image information in output.</param>
-        /// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="enableUserData">Optional. include user data.</param>
-        /// <param name="limit">Return item limit.</param>
-        /// <param name="groupItems">Whether or not to group items into a parent container.</param>
-        /// <response code="200">Latest media returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the latest media.</returns>
-        [HttpGet("Users/{userId}/Items/Latest")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia(
-            [FromRoute, Required] Guid userId,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery] bool? isPlayed,
-            [FromQuery] bool? enableImages,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int limit = 20,
-            [FromQuery] bool groupItems = true)
-        {
-            var user = _userManager.GetUserById(userId);
+        return Ok(item
+            .GetExtras()
+            .Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value))
+            .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
+    }
+
+    /// <summary>
+    /// Gets latest media.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="isPlayed">Filter by items that are played, or not.</param>
+    /// <param name="enableImages">Optional. include image information in output.</param>
+    /// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="enableUserData">Optional. include user data.</param>
+    /// <param name="limit">Return item limit.</param>
+    /// <param name="groupItems">Whether or not to group items into a parent container.</param>
+    /// <response code="200">Latest media returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the latest media.</returns>
+    [HttpGet("Users/{userId}/Items/Latest")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia(
+        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery] bool? isPlayed,
+        [FromQuery] bool? enableImages,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int limit = 20,
+        [FromQuery] bool groupItems = true)
+    {
+        var user = _userManager.GetUserById(userId);
 
-            if (!isPlayed.HasValue)
+        if (!isPlayed.HasValue)
+        {
+            if (user.HidePlayedInLatest)
             {
-                if (user.HidePlayedInLatest)
-                {
-                    isPlayed = false;
-                }
+                isPlayed = false;
             }
+        }
 
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-            var list = _userViewManager.GetLatestItems(
-                new LatestItemsQuery
-                {
-                    GroupItems = groupItems,
-                    IncludeItemTypes = includeItemTypes,
-                    IsPlayed = isPlayed,
-                    Limit = limit,
-                    ParentId = parentId ?? Guid.Empty,
-                    UserId = userId,
-                },
-                dtoOptions);
-
-            var dtos = list.Select(i =>
+        var list = _userViewManager.GetLatestItems(
+            new LatestItemsQuery
             {
-                var item = i.Item2[0];
-                var childCount = 0;
+                GroupItems = groupItems,
+                IncludeItemTypes = includeItemTypes,
+                IsPlayed = isPlayed,
+                Limit = limit,
+                ParentId = parentId ?? Guid.Empty,
+                UserId = userId,
+            },
+            dtoOptions);
+
+        var dtos = list.Select(i =>
+        {
+            var item = i.Item2[0];
+            var childCount = 0;
 
-                if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum))
-                {
-                    item = i.Item1;
-                    childCount = i.Item2.Count;
-                }
+            if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum))
+            {
+                item = i.Item1;
+                childCount = i.Item2.Count;
+            }
 
-                var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user);
+            var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user);
 
-                dto.ChildCount = childCount;
+            dto.ChildCount = childCount;
 
-                return dto;
-            });
+            return dto;
+        });
 
-            return Ok(dtos);
-        }
+        return Ok(dtos);
+    }
 
-        private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
+    private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
+    {
+        if (item is Person)
         {
-            if (item is Person)
-            {
-                var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
-                var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3;
+            var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
+            var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3;
 
-                if (!hasMetdata)
+            if (!hasMetdata)
+            {
+                var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
                 {
-                    var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
-                    {
-                        MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
-                        ImageRefreshMode = MetadataRefreshMode.FullRefresh,
-                        ForceSave = performFullRefresh
-                    };
-
-                    await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false);
-                }
+                    MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+                    ImageRefreshMode = MetadataRefreshMode.FullRefresh,
+                    ForceSave = performFullRefresh
+                };
+
+                await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false);
             }
         }
+    }
 
-        /// <summary>
-        /// Marks the favorite.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param>
-        private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite)
-        {
-            var user = _userManager.GetUserById(userId);
-
-            var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
-
-            // Get the user data for this item
-            var data = _userDataRepository.GetUserData(user, item);
+    /// <summary>
+    /// Marks the favorite.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param>
+    private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite)
+    {
+        var user = _userManager.GetUserById(userId);
 
-            // Set favorite status
-            data.IsFavorite = isFavorite;
+        var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
 
-            _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
+        // Get the user data for this item
+        var data = _userDataRepository.GetUserData(user, item);
 
-            return _userDataRepository.GetUserDataDto(item, user);
-        }
+        // Set favorite status
+        data.IsFavorite = isFavorite;
 
-        /// <summary>
-        /// Updates the user item rating.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="likes">if set to <c>true</c> [likes].</param>
-        private UserItemDataDto UpdateUserItemRatingInternal(Guid userId, Guid itemId, bool? likes)
-        {
-            var user = _userManager.GetUserById(userId);
+        _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
 
-            var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
+        return _userDataRepository.GetUserDataDto(item, user);
+    }
 
-            // Get the user data for this item
-            var data = _userDataRepository.GetUserData(user, item);
+    /// <summary>
+    /// Updates the user item rating.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="likes">if set to <c>true</c> [likes].</param>
+    private UserItemDataDto UpdateUserItemRatingInternal(Guid userId, Guid itemId, bool? likes)
+    {
+        var user = _userManager.GetUserById(userId);
 
-            data.Likes = likes;
+        var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
 
-            _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
+        // Get the user data for this item
+        var data = _userDataRepository.GetUserData(user, item);
 
-            return _userDataRepository.GetUserDataDto(item, user);
-        }
+        data.Likes = likes;
 
-        /// <summary>
-        /// Gets an item's lyrics.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">Lyrics returned.</response>
-        /// <response code="404">Something went wrong. No Lyrics will be returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns>
-        [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<LyricResponse>> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
-        {
-            var user = _userManager.GetUserById(userId);
+        _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
 
-            if (user is null)
-            {
-                return NotFound();
-            }
+        return _userDataRepository.GetUserDataDto(item, user);
+    }
 
-            var item = itemId.Equals(default)
-                ? _libraryManager.GetUserRootFolder()
-                : _libraryManager.GetItemById(itemId);
+    /// <summary>
+    /// Gets an item's lyrics.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Lyrics returned.</response>
+    /// <response code="404">Something went wrong. No Lyrics will be returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns>
+    [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<LyricResponse>> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    {
+        var user = _userManager.GetUserById(userId);
 
-            if (item is null)
-            {
-                return NotFound();
-            }
+        if (user is null)
+        {
+            return NotFound();
+        }
 
-            var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false);
-            if (result is not null)
-            {
-                return Ok(result);
-            }
+        var item = itemId.Equals(default)
+            ? _libraryManager.GetUserRootFolder()
+            : _libraryManager.GetItemById(itemId);
 
+        if (item is null)
+        {
             return NotFound();
         }
+
+        var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false);
+        if (result is not null)
+        {
+            return Ok(result);
+        }
+
+        return NotFound();
     }
 }

+ 97 - 98
Jellyfin.Api/Controllers/UserViewsController.cs

@@ -17,122 +17,121 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// User views controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class UserViewsController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly IUserViewManager _userViewManager;
+    private readonly IDtoService _dtoService;
+    private readonly ILibraryManager _libraryManager;
+
     /// <summary>
-    /// User views controller.
+    /// Initializes a new instance of the <see cref="UserViewsController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class UserViewsController : BaseJellyfinApiController
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    public UserViewsController(
+        IUserManager userManager,
+        IUserViewManager userViewManager,
+        IDtoService dtoService,
+        ILibraryManager libraryManager)
     {
-        private readonly IUserManager _userManager;
-        private readonly IUserViewManager _userViewManager;
-        private readonly IDtoService _dtoService;
-        private readonly ILibraryManager _libraryManager;
+        _userManager = userManager;
+        _userViewManager = userViewManager;
+        _dtoService = dtoService;
+        _libraryManager = libraryManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="UserViewsController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        public UserViewsController(
-            IUserManager userManager,
-            IUserViewManager userViewManager,
-            IDtoService dtoService,
-            ILibraryManager libraryManager)
+    /// <summary>
+    /// Get user views.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param>
+    /// <param name="presetViews">Preset views.</param>
+    /// <param name="includeHidden">Whether or not to include hidden content.</param>
+    /// <response code="200">User views returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the user views.</returns>
+    [HttpGet("Users/{userId}/Views")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public QueryResult<BaseItemDto> GetUserViews(
+        [FromRoute, Required] Guid userId,
+        [FromQuery] bool? includeExternalContent,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews,
+        [FromQuery] bool includeHidden = false)
+    {
+        var query = new UserViewQuery
         {
-            _userManager = userManager;
-            _userViewManager = userViewManager;
-            _dtoService = dtoService;
-            _libraryManager = libraryManager;
-        }
+            UserId = userId,
+            IncludeHidden = includeHidden
+        };
 
-        /// <summary>
-        /// Get user views.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param>
-        /// <param name="presetViews">Preset views.</param>
-        /// <param name="includeHidden">Whether or not to include hidden content.</param>
-        /// <response code="200">User views returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the user views.</returns>
-        [HttpGet("Users/{userId}/Views")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public QueryResult<BaseItemDto> GetUserViews(
-            [FromRoute, Required] Guid userId,
-            [FromQuery] bool? includeExternalContent,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews,
-            [FromQuery] bool includeHidden = false)
+        if (includeExternalContent.HasValue)
         {
-            var query = new UserViewQuery
-            {
-                UserId = userId,
-                IncludeHidden = includeHidden
-            };
+            query.IncludeExternalContent = includeExternalContent.Value;
+        }
 
-            if (includeExternalContent.HasValue)
-            {
-                query.IncludeExternalContent = includeExternalContent.Value;
-            }
+        if (presetViews.Length != 0)
+        {
+            query.PresetViews = presetViews;
+        }
 
-            if (presetViews.Length != 0)
-            {
-                query.PresetViews = presetViews;
-            }
+        var folders = _userViewManager.GetUserViews(query);
 
-            var folders = _userViewManager.GetUserViews(query);
+        var dtoOptions = new DtoOptions().AddClientFields(User);
+        var fields = dtoOptions.Fields.ToList();
 
-            var dtoOptions = new DtoOptions().AddClientFields(User);
-            var fields = dtoOptions.Fields.ToList();
+        fields.Add(ItemFields.PrimaryImageAspectRatio);
+        fields.Add(ItemFields.DisplayPreferencesId);
+        fields.Remove(ItemFields.BasicSyncInfo);
+        dtoOptions.Fields = fields.ToArray();
 
-            fields.Add(ItemFields.PrimaryImageAspectRatio);
-            fields.Add(ItemFields.DisplayPreferencesId);
-            fields.Remove(ItemFields.BasicSyncInfo);
-            dtoOptions.Fields = fields.ToArray();
+        var user = _userManager.GetUserById(userId);
 
-            var user = _userManager.GetUserById(userId);
+        var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user))
+            .ToArray();
 
-            var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user))
-                .ToArray();
+        return new QueryResult<BaseItemDto>(dtos);
+    }
 
-            return new QueryResult<BaseItemDto>(dtos);
+    /// <summary>
+    /// Get user view grouping options.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <response code="200">User view grouping options returned.</response>
+    /// <response code="404">User not found.</response>
+    /// <returns>
+    /// An <see cref="OkResult"/> containing the user view grouping options
+    /// or a <see cref="NotFoundResult"/> if user not found.
+    /// </returns>
+    [HttpGet("Users/{userId}/GroupingOptions")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute, Required] Guid userId)
+    {
+        var user = _userManager.GetUserById(userId);
+        if (user is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get user view grouping options.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <response code="200">User view grouping options returned.</response>
-        /// <response code="404">User not found.</response>
-        /// <returns>
-        /// An <see cref="OkResult"/> containing the user view grouping options
-        /// or a <see cref="NotFoundResult"/> if user not found.
-        /// </returns>
-        [HttpGet("Users/{userId}/GroupingOptions")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute, Required] Guid userId)
-        {
-            var user = _userManager.GetUserById(userId);
-            if (user is null)
+        return Ok(_libraryManager.GetUserRootFolder()
+            .GetChildren(user, true)
+            .OfType<Folder>()
+            .Where(UserView.IsEligibleForGrouping)
+            .Select(i => new SpecialViewOptionDto
             {
-                return NotFound();
-            }
-
-            return Ok(_libraryManager.GetUserRootFolder()
-                .GetChildren(user, true)
-                .OfType<Folder>()
-                .Where(UserView.IsEligibleForGrouping)
-                .Select(i => new SpecialViewOptionDto
-                {
-                    Name = i.Name,
-                    Id = i.Id.ToString("N", CultureInfo.InvariantCulture)
-                })
-                .OrderBy(i => i.Name)
-                .AsEnumerable());
-        }
+                Name = i.Name,
+                Id = i.Id.ToString("N", CultureInfo.InvariantCulture)
+            })
+            .OrderBy(i => i.Name)
+            .AsEnumerable());
     }
 }

+ 56 - 57
Jellyfin.Api/Controllers/VideoAttachmentsController.cs

@@ -10,73 +10,72 @@ using MediaBrowser.Controller.MediaEncoding;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Attachments controller.
+/// </summary>
+[Route("Videos")]
+public class VideoAttachmentsController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IAttachmentExtractor _attachmentExtractor;
+
     /// <summary>
-    /// Attachments controller.
+    /// Initializes a new instance of the <see cref="VideoAttachmentsController"/> class.
     /// </summary>
-    [Route("Videos")]
-    public class VideoAttachmentsController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param>
+    public VideoAttachmentsController(
+        ILibraryManager libraryManager,
+        IAttachmentExtractor attachmentExtractor)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IAttachmentExtractor _attachmentExtractor;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="VideoAttachmentsController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param>
-        public VideoAttachmentsController(
-            ILibraryManager libraryManager,
-            IAttachmentExtractor attachmentExtractor)
-        {
-            _libraryManager = libraryManager;
-            _attachmentExtractor = attachmentExtractor;
-        }
+        _libraryManager = libraryManager;
+        _attachmentExtractor = attachmentExtractor;
+    }
 
-        /// <summary>
-        /// Get video attachment.
-        /// </summary>
-        /// <param name="videoId">Video ID.</param>
-        /// <param name="mediaSourceId">Media Source ID.</param>
-        /// <param name="index">Attachment Index.</param>
-        /// <response code="200">Attachment retrieved.</response>
-        /// <response code="404">Video or attachment not found.</response>
-        /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns>
-        [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")]
-        [ProducesFile(MediaTypeNames.Application.Octet)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> GetAttachment(
-            [FromRoute, Required] Guid videoId,
-            [FromRoute, Required] string mediaSourceId,
-            [FromRoute, Required] int index)
+    /// <summary>
+    /// Get video attachment.
+    /// </summary>
+    /// <param name="videoId">Video ID.</param>
+    /// <param name="mediaSourceId">Media Source ID.</param>
+    /// <param name="index">Attachment Index.</param>
+    /// <response code="200">Attachment retrieved.</response>
+    /// <response code="404">Video or attachment not found.</response>
+    /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns>
+    [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")]
+    [ProducesFile(MediaTypeNames.Application.Octet)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> GetAttachment(
+        [FromRoute, Required] Guid videoId,
+        [FromRoute, Required] string mediaSourceId,
+        [FromRoute, Required] int index)
+    {
+        try
         {
-            try
+            var item = _libraryManager.GetItemById(videoId);
+            if (item is null)
             {
-                var item = _libraryManager.GetItemById(videoId);
-                if (item is null)
-                {
-                    return NotFound();
-                }
+                return NotFound();
+            }
 
-                var (attachment, stream) = await _attachmentExtractor.GetAttachment(
-                        item,
-                        mediaSourceId,
-                        index,
-                        CancellationToken.None)
-                    .ConfigureAwait(false);
+            var (attachment, stream) = await _attachmentExtractor.GetAttachment(
+                    item,
+                    mediaSourceId,
+                    index,
+                    CancellationToken.None)
+                .ConfigureAwait(false);
 
-                var contentType = string.IsNullOrWhiteSpace(attachment.MimeType)
-                    ? MediaTypeNames.Application.Octet
-                    : attachment.MimeType;
+            var contentType = string.IsNullOrWhiteSpace(attachment.MimeType)
+                ? MediaTypeNames.Application.Octet
+                : attachment.MimeType;
 
-                return new FileStreamResult(stream, contentType);
-            }
-            catch (ResourceNotFoundException e)
-            {
-                return NotFound(e.Message);
-            }
+            return new FileStreamResult(stream, contentType);
+        }
+        catch (ResourceNotFoundException e)
+        {
+            return NotFound(e.Message);
         }
     }
 }

+ 585 - 586
Jellyfin.Api/Controllers/VideosController.cs

@@ -32,644 +32,643 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The videos controller.
+/// </summary>
+public class VideosController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IUserManager _userManager;
+    private readonly IDtoService _dtoService;
+    private readonly IDlnaManager _dlnaManager;
+    private readonly IMediaSourceManager _mediaSourceManager;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly IMediaEncoder _mediaEncoder;
+    private readonly IDeviceManager _deviceManager;
+    private readonly TranscodingJobHelper _transcodingJobHelper;
+    private readonly IHttpClientFactory _httpClientFactory;
+    private readonly EncodingHelper _encodingHelper;
+
+    private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="VideosController"/> class.
+    /// </summary>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+    /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+    /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param>
+    /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
+    /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
+    public VideosController(
+        ILibraryManager libraryManager,
+        IUserManager userManager,
+        IDtoService dtoService,
+        IDlnaManager dlnaManager,
+        IMediaSourceManager mediaSourceManager,
+        IServerConfigurationManager serverConfigurationManager,
+        IMediaEncoder mediaEncoder,
+        IDeviceManager deviceManager,
+        TranscodingJobHelper transcodingJobHelper,
+        IHttpClientFactory httpClientFactory,
+        EncodingHelper encodingHelper)
+    {
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+        _dtoService = dtoService;
+        _dlnaManager = dlnaManager;
+        _mediaSourceManager = mediaSourceManager;
+        _serverConfigurationManager = serverConfigurationManager;
+        _mediaEncoder = mediaEncoder;
+        _deviceManager = deviceManager;
+        _transcodingJobHelper = transcodingJobHelper;
+        _httpClientFactory = httpClientFactory;
+        _encodingHelper = encodingHelper;
+    }
+
     /// <summary>
-    /// The videos controller.
+    /// Gets additional parts for a video.
     /// </summary>
-    public class VideosController : BaseJellyfinApiController
+    /// <param name="itemId">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <response code="200">Additional parts returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the parts.</returns>
+    [HttpGet("{itemId}/AdditionalParts")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-        private readonly IDtoService _dtoService;
-        private readonly IDlnaManager _dlnaManager;
-        private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly IMediaEncoder _mediaEncoder;
-        private readonly IDeviceManager _deviceManager;
-        private readonly TranscodingJobHelper _transcodingJobHelper;
-        private readonly IHttpClientFactory _httpClientFactory;
-        private readonly EncodingHelper _encodingHelper;
-
-        private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="VideosController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
-        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
-        /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param>
-        /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
-        /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
-        public VideosController(
-            ILibraryManager libraryManager,
-            IUserManager userManager,
-            IDtoService dtoService,
-            IDlnaManager dlnaManager,
-            IMediaSourceManager mediaSourceManager,
-            IServerConfigurationManager serverConfigurationManager,
-            IMediaEncoder mediaEncoder,
-            IDeviceManager deviceManager,
-            TranscodingJobHelper transcodingJobHelper,
-            IHttpClientFactory httpClientFactory,
-            EncodingHelper encodingHelper)
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+
+        var item = itemId.Equals(default)
+            ? (userId is null || userId.Value.Equals(default)
+                ? _libraryManager.RootFolder
+                : _libraryManager.GetUserRootFolder())
+            : _libraryManager.GetItemById(itemId);
+
+        var dtoOptions = new DtoOptions();
+        dtoOptions = dtoOptions.AddClientFields(User);
+
+        BaseItemDto[] items;
+        if (item is Video video)
         {
-            _libraryManager = libraryManager;
-            _userManager = userManager;
-            _dtoService = dtoService;
-            _dlnaManager = dlnaManager;
-            _mediaSourceManager = mediaSourceManager;
-            _serverConfigurationManager = serverConfigurationManager;
-            _mediaEncoder = mediaEncoder;
-            _deviceManager = deviceManager;
-            _transcodingJobHelper = transcodingJobHelper;
-            _httpClientFactory = httpClientFactory;
-            _encodingHelper = encodingHelper;
+            items = video.GetAdditionalParts()
+                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video))
+                .ToArray();
         }
-
-        /// <summary>
-        /// Gets additional parts for a video.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <response code="200">Additional parts returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the parts.</returns>
-        [HttpGet("{itemId}/AdditionalParts")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
+        else
         {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-
-            var item = itemId.Equals(default)
-                ? (userId is null || userId.Value.Equals(default)
-                    ? _libraryManager.RootFolder
-                    : _libraryManager.GetUserRootFolder())
-                : _libraryManager.GetItemById(itemId);
+            items = Array.Empty<BaseItemDto>();
+        }
 
-            var dtoOptions = new DtoOptions();
-            dtoOptions = dtoOptions.AddClientFields(User);
+        var result = new QueryResult<BaseItemDto>(items);
+        return result;
+    }
 
-            BaseItemDto[] items;
-            if (item is Video video)
-            {
-                items = video.GetAdditionalParts()
-                    .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video))
-                    .ToArray();
-            }
-            else
-            {
-                items = Array.Empty<BaseItemDto>();
-            }
+    /// <summary>
+    /// Removes alternate video sources.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <response code="204">Alternate sources deleted.</response>
+    /// <response code="404">Video not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="NotFoundResult"/> if the video doesn't exist.</returns>
+    [HttpDelete("{itemId}/AlternateSources")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId)
+    {
+        var video = (Video)_libraryManager.GetItemById(itemId);
 
-            var result = new QueryResult<BaseItemDto>(items);
-            return result;
+        if (video is null)
+        {
+            return NotFound("The video either does not exist or the id does not belong to a video.");
         }
 
-        /// <summary>
-        /// Removes alternate video sources.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <response code="204">Alternate sources deleted.</response>
-        /// <response code="404">Video not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="NotFoundResult"/> if the video doesn't exist.</returns>
-        [HttpDelete("{itemId}/AlternateSources")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId)
+        if (video.LinkedAlternateVersions.Length == 0)
         {
-            var video = (Video)_libraryManager.GetItemById(itemId);
+            video = (Video)_libraryManager.GetItemById(video.PrimaryVersionId);
+        }
 
-            if (video is null)
-            {
-                return NotFound("The video either does not exist or the id does not belong to a video.");
-            }
+        foreach (var link in video.GetLinkedAlternateVersions())
+        {
+            link.SetPrimaryVersionId(null);
+            link.LinkedAlternateVersions = Array.Empty<LinkedChild>();
 
-            if (video.LinkedAlternateVersions.Length == 0)
-            {
-                video = (Video)_libraryManager.GetItemById(video.PrimaryVersionId);
-            }
+            await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+        }
 
-            foreach (var link in video.GetLinkedAlternateVersions())
-            {
-                link.SetPrimaryVersionId(null);
-                link.LinkedAlternateVersions = Array.Empty<LinkedChild>();
+        video.LinkedAlternateVersions = Array.Empty<LinkedChild>();
+        video.SetPrimaryVersionId(null);
+        await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
 
-                await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
-            }
+        return NoContent();
+    }
 
-            video.LinkedAlternateVersions = Array.Empty<LinkedChild>();
-            video.SetPrimaryVersionId(null);
-            await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+    /// <summary>
+    /// Merges videos into a single record.
+    /// </summary>
+    /// <param name="ids">Item id list. This allows multiple, comma delimited.</param>
+    /// <response code="204">Videos merged.</response>
+    /// <response code="400">Supply at least 2 video ids.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns>
+    [HttpPost("MergeVersions")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
+    public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+    {
+        var items = ids
+            .Select(i => _libraryManager.GetItemById(i))
+            .OfType<Video>()
+            .OrderBy(i => i.Id)
+            .ToList();
 
-            return NoContent();
+        if (items.Count < 2)
+        {
+            return BadRequest("Please supply at least two videos to merge.");
         }
 
-        /// <summary>
-        /// Merges videos into a single record.
-        /// </summary>
-        /// <param name="ids">Item id list. This allows multiple, comma delimited.</param>
-        /// <response code="204">Videos merged.</response>
-        /// <response code="400">Supply at least 2 video ids.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns>
-        [HttpPost("MergeVersions")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status400BadRequest)]
-        public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+        var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId));
+        if (primaryVersion is null)
         {
-            var items = ids
-                .Select(i => _libraryManager.GetItemById(i))
-                .OfType<Video>()
-                .OrderBy(i => i.Id)
-                .ToList();
-
-            if (items.Count < 2)
-            {
-                return BadRequest("Please supply at least two videos to merge.");
-            }
-
-            var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId));
-            if (primaryVersion is null)
-            {
-                primaryVersion = items
-                    .OrderBy(i =>
+            primaryVersion = items
+                .OrderBy(i =>
+                {
+                    if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile)
                     {
-                        if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile)
-                        {
-                            return 1;
-                        }
-
-                        return 0;
-                    })
-                    .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0)
-                    .First();
-            }
+                        return 1;
+                    }
 
-            var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList();
+                    return 0;
+                })
+                .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0)
+                .First();
+        }
 
-            foreach (var item in items.Where(i => !i.Id.Equals(primaryVersion.Id)))
-            {
-                item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture));
+        var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList();
 
-                await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+        foreach (var item in items.Where(i => !i.Id.Equals(primaryVersion.Id)))
+        {
+            item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture));
 
-                if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase)))
-                {
-                    alternateVersionsOfPrimary.Add(new LinkedChild
-                    {
-                        Path = item.Path,
-                        ItemId = item.Id
-                    });
-                }
+            await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
 
-                foreach (var linkedItem in item.LinkedAlternateVersions)
+            if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase)))
+            {
+                alternateVersionsOfPrimary.Add(new LinkedChild
                 {
-                    if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase)))
-                    {
-                        alternateVersionsOfPrimary.Add(linkedItem);
-                    }
-                }
+                    Path = item.Path,
+                    ItemId = item.Id
+                });
+            }
 
-                if (item.LinkedAlternateVersions.Length > 0)
+            foreach (var linkedItem in item.LinkedAlternateVersions)
+            {
+                if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase)))
                 {
-                    item.LinkedAlternateVersions = Array.Empty<LinkedChild>();
-                    await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+                    alternateVersionsOfPrimary.Add(linkedItem);
                 }
             }
 
-            primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary.ToArray();
-            await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
-            return NoContent();
+            if (item.LinkedAlternateVersions.Length > 0)
+            {
+                item.LinkedAlternateVersions = Array.Empty<LinkedChild>();
+                await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+            }
         }
 
-        /// <summary>
-        /// Gets a video stream.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
-        /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Video stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("{itemId}/stream")]
-        [HttpHead("{itemId}/stream", Name = "HeadVideoStream")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesVideoFile]
-        public async Task<ActionResult> GetVideoStream(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] string? container,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions)
+        primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary.ToArray();
+        await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Gets a video stream.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
+    /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Video stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("{itemId}/stream")]
+    [HttpHead("{itemId}/stream", Name = "HeadVideoStream")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesVideoFile]
+    public async Task<ActionResult> GetVideoStream(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] string? container,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions)
+    {
+        var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
+        // CTS lifecycle is managed internally.
+        var cancellationTokenSource = new CancellationTokenSource();
+        var streamingRequest = new VideoRequestDto
         {
-            var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
-            // CTS lifecycle is managed internally.
-            var cancellationTokenSource = new CancellationTokenSource();
-            var streamingRequest = new VideoRequestDto
-            {
-                Id = itemId,
-                Container = container,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                MaxWidth = maxWidth,
-                MaxHeight = maxHeight,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions
-            };
-
-            var state = await StreamingHelpers.GetStreamingState(
-                    streamingRequest,
-                    HttpContext,
-                    _mediaSourceManager,
-                    _userManager,
-                    _libraryManager,
-                    _serverConfigurationManager,
-                    _mediaEncoder,
-                    _encodingHelper,
-                    _dlnaManager,
-                    _deviceManager,
-                    _transcodingJobHelper,
-                    _transcodingJobType,
-                    cancellationTokenSource.Token)
-                .ConfigureAwait(false);
-
-            if (@static.HasValue && @static.Value && state.DirectStreamProvider is not null)
-            {
-                StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager);
+            Id = itemId,
+            Container = container,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            MaxWidth = maxWidth,
+            MaxHeight = maxHeight,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions
+        };
+
+        var state = await StreamingHelpers.GetStreamingState(
+                streamingRequest,
+                HttpContext,
+                _mediaSourceManager,
+                _userManager,
+                _libraryManager,
+                _serverConfigurationManager,
+                _mediaEncoder,
+                _encodingHelper,
+                _dlnaManager,
+                _deviceManager,
+                _transcodingJobHelper,
+                _transcodingJobType,
+                cancellationTokenSource.Token)
+            .ConfigureAwait(false);
 
-                var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId);
-                if (liveStreamInfo is null)
-                {
-                    return NotFound();
-                }
+        if (@static.HasValue && @static.Value && state.DirectStreamProvider is not null)
+        {
+            StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager);
 
-                var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream());
-                // TODO (moved from MediaBrowser.Api): Don't hardcode contentType
-                return File(liveStream, MimeTypes.GetMimeType("file.ts"));
+            var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId);
+            if (liveStreamInfo is null)
+            {
+                return NotFound();
             }
 
-            // Static remote stream
-            if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http)
-            {
-                StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager);
+            var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream());
+            // TODO (moved from MediaBrowser.Api): Don't hardcode contentType
+            return File(liveStream, MimeTypes.GetMimeType("file.ts"));
+        }
 
-                var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
-                return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, HttpContext).ConfigureAwait(false);
-            }
+        // Static remote stream
+        if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http)
+        {
+            StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager);
 
-            if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File)
-            {
-                return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically");
-            }
+            var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
+            return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, HttpContext).ConfigureAwait(false);
+        }
 
-            var outputPath = state.OutputFilePath;
-            var outputPathExists = System.IO.File.Exists(outputPath);
+        if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File)
+        {
+            return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically");
+        }
 
-            var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
-            var isTranscodeCached = outputPathExists && transcodingJob is not null;
+        var outputPath = state.OutputFilePath;
+        var outputPathExists = System.IO.File.Exists(outputPath);
 
-            StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, state.Request.StartTimeTicks, Request, _dlnaManager);
+        var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
+        var isTranscodeCached = outputPathExists && transcodingJob is not null;
 
-            // Static stream
-            if (@static.HasValue && @static.Value)
-            {
-                var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
+        StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, state.Request.StartTimeTicks, Request, _dlnaManager);
 
-                if (state.MediaSource.IsInfiniteStream)
-                {
-                    var liveStream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper);
-                    return File(liveStream, contentType);
-                }
+        // Static stream
+        if (@static.HasValue && @static.Value)
+        {
+            var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
 
-                return FileStreamResponseHelpers.GetStaticFileResult(
-                    state.MediaPath,
-                    contentType);
+            if (state.MediaSource.IsInfiniteStream)
+            {
+                var liveStream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper);
+                return File(liveStream, contentType);
             }
 
-            // Need to start ffmpeg (because media can't be returned directly)
-            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
-            var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast");
-            return await FileStreamResponseHelpers.GetTranscodedFile(
-                state,
-                isHeadRequest,
-                HttpContext,
-                _transcodingJobHelper,
-                ffmpegCommandLineArguments,
-                _transcodingJobType,
-                cancellationTokenSource).ConfigureAwait(false);
+            return FileStreamResponseHelpers.GetStaticFileResult(
+                state.MediaPath,
+                contentType);
         }
 
-        /// <summary>
-        /// Gets a video stream.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
-        /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Video stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("{itemId}/stream.{container}")]
-        [HttpHead("{itemId}/stream.{container}", Name = "HeadVideoStreamByContainer")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesVideoFile]
-        public Task<ActionResult> GetVideoStreamByContainer(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] string container,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions)
-        {
-            return GetVideoStream(
-                itemId,
-                container,
-                @static,
-                @params,
-                tag,
-                deviceProfileId,
-                playSessionId,
-                segmentContainer,
-                segmentLength,
-                minSegments,
-                mediaSourceId,
-                deviceId,
-                audioCodec,
-                enableAutoStreamCopy,
-                allowVideoStreamCopy,
-                allowAudioStreamCopy,
-                breakOnNonKeyFrames,
-                audioSampleRate,
-                maxAudioBitDepth,
-                audioBitRate,
-                audioChannels,
-                maxAudioChannels,
-                profile,
-                level,
-                framerate,
-                maxFramerate,
-                copyTimestamps,
-                startTimeTicks,
-                width,
-                height,
-                maxWidth,
-                maxHeight,
-                videoBitRate,
-                subtitleStreamIndex,
-                subtitleMethod,
-                maxRefFrames,
-                maxVideoBitDepth,
-                requireAvc,
-                deInterlace,
-                requireNonAnamorphic,
-                transcodingMaxAudioChannels,
-                cpuCoreLimit,
-                liveStreamId,
-                enableMpegtsM2TsMode,
-                videoCodec,
-                subtitleCodec,
-                transcodeReasons,
-                audioStreamIndex,
-                videoStreamIndex,
-                context,
-                streamOptions);
-        }
+        // Need to start ffmpeg (because media can't be returned directly)
+        var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+        var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast");
+        return await FileStreamResponseHelpers.GetTranscodedFile(
+            state,
+            isHeadRequest,
+            HttpContext,
+            _transcodingJobHelper,
+            ffmpegCommandLineArguments,
+            _transcodingJobType,
+            cancellationTokenSource).ConfigureAwait(false);
+    }
+
+    /// <summary>
+    /// Gets a video stream.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
+    /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Video stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("{itemId}/stream.{container}")]
+    [HttpHead("{itemId}/stream.{container}", Name = "HeadVideoStreamByContainer")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesVideoFile]
+    public Task<ActionResult> GetVideoStreamByContainer(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] string container,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions)
+    {
+        return GetVideoStream(
+            itemId,
+            container,
+            @static,
+            @params,
+            tag,
+            deviceProfileId,
+            playSessionId,
+            segmentContainer,
+            segmentLength,
+            minSegments,
+            mediaSourceId,
+            deviceId,
+            audioCodec,
+            enableAutoStreamCopy,
+            allowVideoStreamCopy,
+            allowAudioStreamCopy,
+            breakOnNonKeyFrames,
+            audioSampleRate,
+            maxAudioBitDepth,
+            audioBitRate,
+            audioChannels,
+            maxAudioChannels,
+            profile,
+            level,
+            framerate,
+            maxFramerate,
+            copyTimestamps,
+            startTimeTicks,
+            width,
+            height,
+            maxWidth,
+            maxHeight,
+            videoBitRate,
+            subtitleStreamIndex,
+            subtitleMethod,
+            maxRefFrames,
+            maxVideoBitDepth,
+            requireAvc,
+            deInterlace,
+            requireNonAnamorphic,
+            transcodingMaxAudioChannels,
+            cpuCoreLimit,
+            liveStreamId,
+            enableMpegtsM2TsMode,
+            videoCodec,
+            subtitleCodec,
+            transcodeReasons,
+            audioStreamIndex,
+            videoStreamIndex,
+            context,
+            streamOptions);
     }
 }

+ 165 - 166
Jellyfin.Api/Controllers/YearsController.cs

@@ -19,208 +19,207 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Years controller.
+/// </summary>
+[Authorize(Policy = Policies.DefaultAuthorization)]
+public class YearsController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IUserManager _userManager;
+    private readonly IDtoService _dtoService;
+
     /// <summary>
-    /// Years controller.
+    /// Initializes a new instance of the <see cref="YearsController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class YearsController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    public YearsController(
+        ILibraryManager libraryManager,
+        IUserManager userManager,
+        IDtoService dtoService)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-        private readonly IDtoService _dtoService;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="YearsController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        public YearsController(
-            ILibraryManager libraryManager,
-            IUserManager userManager,
-            IDtoService dtoService)
-        {
-            _libraryManager = libraryManager;
-            _userManager = userManager;
-            _dtoService = dtoService;
-        }
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+        _dtoService = dtoService;
+    }
 
-        /// <summary>
-        /// Get years.
-        /// </summary>
-        /// <param name="startIndex">Skips over a given number of items within the results. Use for paging.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="userId">User Id.</param>
-        /// <param name="recursive">Search recursively.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <response code="200">Year query returned.</response>
-        /// <returns> A <see cref="QueryResult{BaseItemDto}"/> containing the year result.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetYears(
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] Guid? userId,
-            [FromQuery] bool recursive = true,
-            [FromQuery] bool? enableImages = true)
-        {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+    /// <summary>
+    /// Get years.
+    /// </summary>
+    /// <param name="startIndex">Skips over a given number of items within the results. Use for paging.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="userId">User Id.</param>
+    /// <param name="recursive">Search recursively.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <response code="200">Year query returned.</response>
+    /// <returns> A <see cref="QueryResult{BaseItemDto}"/> containing the year result.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetYears(
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] Guid? userId,
+        [FromQuery] bool recursive = true,
+        [FromQuery] bool? enableImages = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-            User? user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
+        User? user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
 
-            var query = new InternalItemsQuery(user)
-            {
-                ExcludeItemTypes = excludeItemTypes,
-                IncludeItemTypes = includeItemTypes,
-                MediaTypes = mediaTypes,
-                DtoOptions = dtoOptions
-            };
+        var query = new InternalItemsQuery(user)
+        {
+            ExcludeItemTypes = excludeItemTypes,
+            IncludeItemTypes = includeItemTypes,
+            MediaTypes = mediaTypes,
+            DtoOptions = dtoOptions
+        };
+
+        bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes);
 
-            bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes);
+        IList<BaseItem> items;
+        if (parentItem.IsFolder)
+        {
+            var folder = (Folder)parentItem;
 
-            IList<BaseItem> items;
-            if (parentItem.IsFolder)
+            if (userId.Equals(default))
             {
-                var folder = (Folder)parentItem;
-
-                if (userId.Equals(default))
-                {
-                    items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList();
-                }
-                else
-                {
-                    items = recursive ? folder.GetRecursiveChildren(user, query).ToList() : folder.GetChildren(user, true).Where(Filter).ToList();
-                }
+                items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList();
             }
             else
             {
-                items = new[] { parentItem }.Where(Filter).ToList();
+                items = recursive ? folder.GetRecursiveChildren(user, query).ToList() : folder.GetChildren(user, true).Where(Filter).ToList();
             }
+        }
+        else
+        {
+            items = new[] { parentItem }.Where(Filter).ToList();
+        }
 
-            var extractedItems = GetAllItems(items);
+        var extractedItems = GetAllItems(items);
 
-            var filteredItems = _libraryManager.Sort(extractedItems, user, RequestHelpers.GetOrderBy(sortBy, sortOrder));
+        var filteredItems = _libraryManager.Sort(extractedItems, user, RequestHelpers.GetOrderBy(sortBy, sortOrder));
 
-            var ibnItemsArray = filteredItems.ToList();
+        var ibnItemsArray = filteredItems.ToList();
 
-            IEnumerable<BaseItem> ibnItems = ibnItemsArray;
+        IEnumerable<BaseItem> ibnItems = ibnItemsArray;
 
-            if (startIndex.HasValue || limit.HasValue)
+        if (startIndex.HasValue || limit.HasValue)
+        {
+            if (startIndex.HasValue)
             {
-                if (startIndex.HasValue)
-                {
-                    ibnItems = ibnItems.Skip(startIndex.Value);
-                }
-
-                if (limit.HasValue)
-                {
-                    ibnItems = ibnItems.Take(limit.Value);
-                }
+                ibnItems = ibnItems.Skip(startIndex.Value);
             }
 
-            var tuples = ibnItems.Select(i => new Tuple<BaseItem, List<BaseItem>>(i, new List<BaseItem>()));
-
-            var dtos = tuples.Select(i => _dtoService.GetItemByNameDto(i.Item1, dtoOptions, i.Item2, user));
-
-            var result = new QueryResult<BaseItemDto>(
-                startIndex,
-                ibnItemsArray.Count,
-                dtos.Where(i => i is not null).ToArray());
-            return result;
-        }
-
-        /// <summary>
-        /// Gets a year.
-        /// </summary>
-        /// <param name="year">The year.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <response code="200">Year returned.</response>
-        /// <response code="404">Year not found.</response>
-        /// <returns>
-        /// An <see cref="OkResult"/> containing the year,
-        /// or a <see cref="NotFoundResult"/> if year not found.
-        /// </returns>
-        [HttpGet("{year}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<BaseItemDto> GetYear([FromRoute, Required] int year, [FromQuery] Guid? userId)
-        {
-            var item = _libraryManager.GetYear(year);
-            if (item is null)
+            if (limit.HasValue)
             {
-                return NotFound();
+                ibnItems = ibnItems.Take(limit.Value);
             }
+        }
 
-            var dtoOptions = new DtoOptions()
-                .AddClientFields(User);
+        var tuples = ibnItems.Select(i => new Tuple<BaseItem, List<BaseItem>>(i, new List<BaseItem>()));
 
-            if (userId.HasValue && !userId.Value.Equals(default))
-            {
-                var user = _userManager.GetUserById(userId.Value);
-                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
-            }
+        var dtos = tuples.Select(i => _dtoService.GetItemByNameDto(i.Item1, dtoOptions, i.Item2, user));
+
+        var result = new QueryResult<BaseItemDto>(
+            startIndex,
+            ibnItemsArray.Count,
+            dtos.Where(i => i is not null).ToArray());
+        return result;
+    }
 
-            return _dtoService.GetBaseItemDto(item, dtoOptions);
+    /// <summary>
+    /// Gets a year.
+    /// </summary>
+    /// <param name="year">The year.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <response code="200">Year returned.</response>
+    /// <response code="404">Year not found.</response>
+    /// <returns>
+    /// An <see cref="OkResult"/> containing the year,
+    /// or a <see cref="NotFoundResult"/> if year not found.
+    /// </returns>
+    [HttpGet("{year}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<BaseItemDto> GetYear([FromRoute, Required] int year, [FromQuery] Guid? userId)
+    {
+        var item = _libraryManager.GetYear(year);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        private bool FilterItem(BaseItem f, IReadOnlyCollection<BaseItemKind> excludeItemTypes, IReadOnlyCollection<BaseItemKind> includeItemTypes, IReadOnlyCollection<string> mediaTypes)
+        var dtoOptions = new DtoOptions()
+            .AddClientFields(User);
+
+        if (userId.HasValue && !userId.Value.Equals(default))
         {
-            var baseItemKind = f.GetBaseItemKind();
-            // Exclude item types
-            if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(baseItemKind))
-            {
-                return false;
-            }
+            var user = _userManager.GetUserById(userId.Value);
+            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+        }
 
-            // Include item types
-            if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(baseItemKind))
-            {
-                return false;
-            }
+        return _dtoService.GetBaseItemDto(item, dtoOptions);
+    }
 
-            // Include MediaTypes
-            if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparison.OrdinalIgnoreCase))
-            {
-                return false;
-            }
+    private bool FilterItem(BaseItem f, IReadOnlyCollection<BaseItemKind> excludeItemTypes, IReadOnlyCollection<BaseItemKind> includeItemTypes, IReadOnlyCollection<string> mediaTypes)
+    {
+        var baseItemKind = f.GetBaseItemKind();
+        // Exclude item types
+        if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(baseItemKind))
+        {
+            return false;
+        }
 
-            return true;
+        // Include item types
+        if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(baseItemKind))
+        {
+            return false;
         }
 
-        private IEnumerable<BaseItem> GetAllItems(IEnumerable<BaseItem> items)
+        // Include MediaTypes
+        if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparison.OrdinalIgnoreCase))
         {
-            return items
-                .Select(i => i.ProductionYear ?? 0)
-                .Where(i => i > 0)
-                .Distinct()
-                .Select(year => _libraryManager.GetYear(year));
+            return false;
         }
+
+        return true;
+    }
+
+    private IEnumerable<BaseItem> GetAllItems(IEnumerable<BaseItem> items)
+    {
+        return items
+            .Select(i => i.ProductionYear ?? 0)
+            .Where(i => i > 0)
+            .Distinct()
+            .Select(year => _libraryManager.GetYear(year));
     }
 }

+ 85 - 86
Jellyfin.Api/Extensions/DtoExtensions.cs

@@ -7,110 +7,109 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Http;
 
-namespace Jellyfin.Api.Extensions
+namespace Jellyfin.Api.Extensions;
+
+/// <summary>
+/// Dto Extensions.
+/// </summary>
+public static class DtoExtensions
 {
     /// <summary>
-    /// Dto Extensions.
+    /// Add additional fields depending on client.
     /// </summary>
-    public static class DtoExtensions
+    /// <remarks>
+    /// Use in place of GetDtoOptions.
+    /// Legacy order: 2.
+    /// </remarks>
+    /// <param name="dtoOptions">DtoOptions object.</param>
+    /// <param name="user">Current claims principal.</param>
+    /// <returns>Modified DtoOptions object.</returns>
+    internal static DtoOptions AddClientFields(
+        this DtoOptions dtoOptions, ClaimsPrincipal user)
     {
-        /// <summary>
-        /// Add additional fields depending on client.
-        /// </summary>
-        /// <remarks>
-        /// Use in place of GetDtoOptions.
-        /// Legacy order: 2.
-        /// </remarks>
-        /// <param name="dtoOptions">DtoOptions object.</param>
-        /// <param name="user">Current claims principal.</param>
-        /// <returns>Modified DtoOptions object.</returns>
-        internal static DtoOptions AddClientFields(
-            this DtoOptions dtoOptions, ClaimsPrincipal user)
-        {
-            dtoOptions.Fields ??= Array.Empty<ItemFields>();
+        dtoOptions.Fields ??= Array.Empty<ItemFields>();
 
-            string? client = user.GetClient();
+        string? client = user.GetClient();
 
-            // No client in claim
-            if (string.IsNullOrEmpty(client))
-            {
-                return dtoOptions;
-            }
+        // No client in claim
+        if (string.IsNullOrEmpty(client))
+        {
+            return dtoOptions;
+        }
 
-            if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount))
+        if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount))
+        {
+            if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 ||
+                client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 ||
+                client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 ||
+                client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1)
             {
-                if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 ||
-                    client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 ||
-                    client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 ||
-                    client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1)
-                {
-                    int oldLen = dtoOptions.Fields.Count;
-                    var arr = new ItemFields[oldLen + 1];
-                    dtoOptions.Fields.CopyTo(arr, 0);
-                    arr[oldLen] = ItemFields.RecursiveItemCount;
-                    dtoOptions.Fields = arr;
-                }
+                int oldLen = dtoOptions.Fields.Count;
+                var arr = new ItemFields[oldLen + 1];
+                dtoOptions.Fields.CopyTo(arr, 0);
+                arr[oldLen] = ItemFields.RecursiveItemCount;
+                dtoOptions.Fields = arr;
             }
+        }
 
-            if (!dtoOptions.ContainsField(ItemFields.ChildCount))
+        if (!dtoOptions.ContainsField(ItemFields.ChildCount))
+        {
+            if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 ||
+                client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 ||
+                client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 ||
+                client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1 ||
+                client.IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1 ||
+                client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 ||
+                client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1)
             {
-                if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 ||
-                    client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 ||
-                    client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 ||
-                    client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1 ||
-                    client.IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1 ||
-                    client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 ||
-                    client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1)
-                {
-                    int oldLen = dtoOptions.Fields.Count;
-                    var arr = new ItemFields[oldLen + 1];
-                    dtoOptions.Fields.CopyTo(arr, 0);
-                    arr[oldLen] = ItemFields.ChildCount;
-                    dtoOptions.Fields = arr;
-                }
+                int oldLen = dtoOptions.Fields.Count;
+                var arr = new ItemFields[oldLen + 1];
+                dtoOptions.Fields.CopyTo(arr, 0);
+                arr[oldLen] = ItemFields.ChildCount;
+                dtoOptions.Fields = arr;
             }
-
-            return dtoOptions;
         }
 
-        /// <summary>
-        /// Add additional DtoOptions.
-        /// </summary>
-        /// <remarks>
-        /// Converted from IHasDtoOptions.
-        /// Legacy order: 3.
-        /// </remarks>
-        /// <param name="dtoOptions">DtoOptions object.</param>
-        /// <param name="enableImages">Enable images.</param>
-        /// <param name="enableUserData">Enable user data.</param>
-        /// <param name="imageTypeLimit">Image type limit.</param>
-        /// <param name="enableImageTypes">Enable image types.</param>
-        /// <returns>Modified DtoOptions object.</returns>
-        internal static DtoOptions AddAdditionalDtoOptions(
-            this DtoOptions dtoOptions,
-            bool? enableImages,
-            bool? enableUserData,
-            int? imageTypeLimit,
-            IReadOnlyList<ImageType> enableImageTypes)
-        {
-            dtoOptions.EnableImages = enableImages ?? true;
+        return dtoOptions;
+    }
 
-            if (imageTypeLimit.HasValue)
-            {
-                dtoOptions.ImageTypeLimit = imageTypeLimit.Value;
-            }
+    /// <summary>
+    /// Add additional DtoOptions.
+    /// </summary>
+    /// <remarks>
+    /// Converted from IHasDtoOptions.
+    /// Legacy order: 3.
+    /// </remarks>
+    /// <param name="dtoOptions">DtoOptions object.</param>
+    /// <param name="enableImages">Enable images.</param>
+    /// <param name="enableUserData">Enable user data.</param>
+    /// <param name="imageTypeLimit">Image type limit.</param>
+    /// <param name="enableImageTypes">Enable image types.</param>
+    /// <returns>Modified DtoOptions object.</returns>
+    internal static DtoOptions AddAdditionalDtoOptions(
+        this DtoOptions dtoOptions,
+        bool? enableImages,
+        bool? enableUserData,
+        int? imageTypeLimit,
+        IReadOnlyList<ImageType> enableImageTypes)
+    {
+        dtoOptions.EnableImages = enableImages ?? true;
 
-            if (enableUserData.HasValue)
-            {
-                dtoOptions.EnableUserData = enableUserData.Value;
-            }
+        if (imageTypeLimit.HasValue)
+        {
+            dtoOptions.ImageTypeLimit = imageTypeLimit.Value;
+        }
 
-            if (enableImageTypes.Count != 0)
-            {
-                dtoOptions.ImageTypes = enableImageTypes;
-            }
+        if (enableUserData.HasValue)
+        {
+            dtoOptions.EnableUserData = enableUserData.Value;
+        }
 
-            return dtoOptions;
+        if (enableImageTypes.Count != 0)
+        {
+            dtoOptions.ImageTypes = enableImageTypes;
         }
+
+        return dtoOptions;
     }
 }

+ 10 - 11
Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs

@@ -2,20 +2,19 @@ using Jellyfin.Extensions.Json;
 using Microsoft.AspNetCore.Mvc.Formatters;
 using Microsoft.Net.Http.Headers;
 
-namespace Jellyfin.Api.Formatters
+namespace Jellyfin.Api.Formatters;
+
+/// <summary>
+/// Camel Case Json Profile Formatter.
+/// </summary>
+public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter
 {
     /// <summary>
-    /// Camel Case Json Profile Formatter.
+    /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class.
     /// </summary>
-    public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter
+    public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCaseOptions)
     {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class.
-        /// </summary>
-        public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCaseOptions)
-        {
-            SupportedMediaTypes.Clear();
-            SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.CamelCaseMediaType));
-        }
+        SupportedMediaTypes.Clear();
+        SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.CamelCaseMediaType));
     }
 }

+ 22 - 23
Jellyfin.Api/Formatters/CssOutputFormatter.cs

@@ -3,34 +3,33 @@ using System.Threading.Tasks;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc.Formatters;
 
-namespace Jellyfin.Api.Formatters
+namespace Jellyfin.Api.Formatters;
+
+/// <summary>
+/// Css output formatter.
+/// </summary>
+public class CssOutputFormatter : TextOutputFormatter
 {
     /// <summary>
-    /// Css output formatter.
+    /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class.
     /// </summary>
-    public class CssOutputFormatter : TextOutputFormatter
+    public CssOutputFormatter()
     {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class.
-        /// </summary>
-        public CssOutputFormatter()
-        {
-            SupportedMediaTypes.Add("text/css");
+        SupportedMediaTypes.Add("text/css");
 
-            SupportedEncodings.Add(Encoding.UTF8);
-            SupportedEncodings.Add(Encoding.Unicode);
-        }
+        SupportedEncodings.Add(Encoding.UTF8);
+        SupportedEncodings.Add(Encoding.Unicode);
+    }
 
-        /// <summary>
-        /// Write context object to stream.
-        /// </summary>
-        /// <param name="context">Writer context.</param>
-        /// <param name="selectedEncoding">Unused. Writer encoding.</param>
-        /// <returns>Write stream task.</returns>
-        public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
-        {
-            var stringResponse = context.Object?.ToString();
-            return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse);
-        }
+    /// <summary>
+    /// Write context object to stream.
+    /// </summary>
+    /// <param name="context">Writer context.</param>
+    /// <param name="selectedEncoding">Unused. Writer encoding.</param>
+    /// <returns>Write stream task.</returns>
+    public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
+    {
+        var stringResponse = context.Object?.ToString();
+        return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse);
     }
 }

+ 12 - 13
Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs

@@ -3,22 +3,21 @@ using Jellyfin.Extensions.Json;
 using Microsoft.AspNetCore.Mvc.Formatters;
 using Microsoft.Net.Http.Headers;
 
-namespace Jellyfin.Api.Formatters
+namespace Jellyfin.Api.Formatters;
+
+/// <summary>
+/// Pascal Case Json Profile Formatter.
+/// </summary>
+public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter
 {
     /// <summary>
-    /// Pascal Case Json Profile Formatter.
+    /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class.
     /// </summary>
-    public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter
+    public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCaseOptions)
     {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class.
-        /// </summary>
-        public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCaseOptions)
-        {
-            SupportedMediaTypes.Clear();
-            // Add application/json for default formatter
-            SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json));
-            SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.PascalCaseMediaType));
-        }
+        SupportedMediaTypes.Clear();
+        // Add application/json for default formatter
+        SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json));
+        SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.PascalCaseMediaType));
     }
 }

+ 18 - 19
Jellyfin.Api/Formatters/XmlOutputFormatter.cs

@@ -4,30 +4,29 @@ using System.Threading.Tasks;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc.Formatters;
 
-namespace Jellyfin.Api.Formatters
+namespace Jellyfin.Api.Formatters;
+
+/// <summary>
+/// Xml output formatter.
+/// </summary>
+public class XmlOutputFormatter : TextOutputFormatter
 {
     /// <summary>
-    /// Xml output formatter.
+    /// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
     /// </summary>
-    public class XmlOutputFormatter : TextOutputFormatter
+    public XmlOutputFormatter()
     {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
-        /// </summary>
-        public XmlOutputFormatter()
-        {
-            SupportedMediaTypes.Clear();
-            SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
+        SupportedMediaTypes.Clear();
+        SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
 
-            SupportedEncodings.Add(Encoding.UTF8);
-            SupportedEncodings.Add(Encoding.Unicode);
-        }
+        SupportedEncodings.Add(Encoding.UTF8);
+        SupportedEncodings.Add(Encoding.Unicode);
+    }
 
-        /// <inheritdoc />
-        public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
-        {
-            var stringResponse = context.Object?.ToString();
-            return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse);
-        }
+    /// <inheritdoc />
+    public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
+    {
+        var stringResponse = context.Object?.ToString();
+        return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse);
     }
 }

+ 136 - 137
Jellyfin.Api/Helpers/AudioHelper.cs

@@ -16,165 +16,164 @@ using MediaBrowser.Model.Net;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Helpers
+namespace Jellyfin.Api.Helpers;
+
+/// <summary>
+/// Audio helper.
+/// </summary>
+public class AudioHelper
 {
+    private readonly IDlnaManager _dlnaManager;
+    private readonly IUserManager _userManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IMediaSourceManager _mediaSourceManager;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly IMediaEncoder _mediaEncoder;
+    private readonly IDeviceManager _deviceManager;
+    private readonly TranscodingJobHelper _transcodingJobHelper;
+    private readonly IHttpClientFactory _httpClientFactory;
+    private readonly IHttpContextAccessor _httpContextAccessor;
+    private readonly EncodingHelper _encodingHelper;
+
     /// <summary>
-    /// Audio helper.
+    /// Initializes a new instance of the <see cref="AudioHelper"/> class.
     /// </summary>
-    public class AudioHelper
+    /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+    /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+    /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param>
+    /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
+    /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+    /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
+    public AudioHelper(
+        IDlnaManager dlnaManager,
+        IUserManager userManager,
+        ILibraryManager libraryManager,
+        IMediaSourceManager mediaSourceManager,
+        IServerConfigurationManager serverConfigurationManager,
+        IMediaEncoder mediaEncoder,
+        IDeviceManager deviceManager,
+        TranscodingJobHelper transcodingJobHelper,
+        IHttpClientFactory httpClientFactory,
+        IHttpContextAccessor httpContextAccessor,
+        EncodingHelper encodingHelper)
     {
-        private readonly IDlnaManager _dlnaManager;
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly IMediaEncoder _mediaEncoder;
-        private readonly IDeviceManager _deviceManager;
-        private readonly TranscodingJobHelper _transcodingJobHelper;
-        private readonly IHttpClientFactory _httpClientFactory;
-        private readonly IHttpContextAccessor _httpContextAccessor;
-        private readonly EncodingHelper _encodingHelper;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="AudioHelper"/> class.
-        /// </summary>
-        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
-        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
-        /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param>
-        /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
-        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
-        /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
-        public AudioHelper(
-            IDlnaManager dlnaManager,
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IMediaSourceManager mediaSourceManager,
-            IServerConfigurationManager serverConfigurationManager,
-            IMediaEncoder mediaEncoder,
-            IDeviceManager deviceManager,
-            TranscodingJobHelper transcodingJobHelper,
-            IHttpClientFactory httpClientFactory,
-            IHttpContextAccessor httpContextAccessor,
-            EncodingHelper encodingHelper)
+        _dlnaManager = dlnaManager;
+        _userManager = userManager;
+        _libraryManager = libraryManager;
+        _mediaSourceManager = mediaSourceManager;
+        _serverConfigurationManager = serverConfigurationManager;
+        _mediaEncoder = mediaEncoder;
+        _deviceManager = deviceManager;
+        _transcodingJobHelper = transcodingJobHelper;
+        _httpClientFactory = httpClientFactory;
+        _httpContextAccessor = httpContextAccessor;
+        _encodingHelper = encodingHelper;
+    }
+
+    /// <summary>
+    /// Get audio stream.
+    /// </summary>
+    /// <param name="transcodingJobType">Transcoding job type.</param>
+    /// <param name="streamingRequest">Streaming controller.Request dto.</param>
+    /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns>
+    public async Task<ActionResult> GetAudioStream(
+        TranscodingJobType transcodingJobType,
+        StreamingRequestDto streamingRequest)
+    {
+        if (_httpContextAccessor.HttpContext is null)
         {
-            _dlnaManager = dlnaManager;
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _mediaSourceManager = mediaSourceManager;
-            _serverConfigurationManager = serverConfigurationManager;
-            _mediaEncoder = mediaEncoder;
-            _deviceManager = deviceManager;
-            _transcodingJobHelper = transcodingJobHelper;
-            _httpClientFactory = httpClientFactory;
-            _httpContextAccessor = httpContextAccessor;
-            _encodingHelper = encodingHelper;
+            throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext));
         }
 
-        /// <summary>
-        /// Get audio stream.
-        /// </summary>
-        /// <param name="transcodingJobType">Transcoding job type.</param>
-        /// <param name="streamingRequest">Streaming controller.Request dto.</param>
-        /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns>
-        public async Task<ActionResult> GetAudioStream(
-            TranscodingJobType transcodingJobType,
-            StreamingRequestDto streamingRequest)
-        {
-            if (_httpContextAccessor.HttpContext is null)
-            {
-                throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext));
-            }
+        bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head;
 
-            bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head;
-
-            // CTS lifecycle is managed internally.
-            var cancellationTokenSource = new CancellationTokenSource();
-
-            using var state = await StreamingHelpers.GetStreamingState(
-                    streamingRequest,
-                    _httpContextAccessor.HttpContext,
-                    _mediaSourceManager,
-                    _userManager,
-                    _libraryManager,
-                    _serverConfigurationManager,
-                    _mediaEncoder,
-                    _encodingHelper,
-                    _dlnaManager,
-                    _deviceManager,
-                    _transcodingJobHelper,
-                    transcodingJobType,
-                    cancellationTokenSource.Token)
-                .ConfigureAwait(false);
-
-            if (streamingRequest.Static && state.DirectStreamProvider is not null)
-            {
-                StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
+        // CTS lifecycle is managed internally.
+        var cancellationTokenSource = new CancellationTokenSource();
 
-                var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId);
-                if (liveStreamInfo is null)
-                {
-                    throw new FileNotFoundException();
-                }
+        using var state = await StreamingHelpers.GetStreamingState(
+                streamingRequest,
+                _httpContextAccessor.HttpContext,
+                _mediaSourceManager,
+                _userManager,
+                _libraryManager,
+                _serverConfigurationManager,
+                _mediaEncoder,
+                _encodingHelper,
+                _dlnaManager,
+                _deviceManager,
+                _transcodingJobHelper,
+                transcodingJobType,
+                cancellationTokenSource.Token)
+            .ConfigureAwait(false);
 
-                var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream());
-                // TODO (moved from MediaBrowser.Api): Don't hardcode contentType
-                return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file.ts"));
-            }
+        if (streamingRequest.Static && state.DirectStreamProvider is not null)
+        {
+            StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
 
-            // Static remote stream
-            if (streamingRequest.Static && state.InputProtocol == MediaProtocol.Http)
+            var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId);
+            if (liveStreamInfo is null)
             {
-                StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
-
-                var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
-                return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, _httpContextAccessor.HttpContext).ConfigureAwait(false);
+                throw new FileNotFoundException();
             }
 
-            if (streamingRequest.Static && state.InputProtocol != MediaProtocol.File)
-            {
-                return new BadRequestObjectResult($"Input protocol {state.InputProtocol} cannot be streamed statically");
-            }
+            var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream());
+            // TODO (moved from MediaBrowser.Api): Don't hardcode contentType
+            return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file.ts"));
+        }
 
-            var outputPath = state.OutputFilePath;
-            var outputPathExists = File.Exists(outputPath);
+        // Static remote stream
+        if (streamingRequest.Static && state.InputProtocol == MediaProtocol.Http)
+        {
+            StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
 
-            var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
-            var isTranscodeCached = outputPathExists && transcodingJob is not null;
+            var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
+            return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, _httpContextAccessor.HttpContext).ConfigureAwait(false);
+        }
 
-            StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, streamingRequest.Static || isTranscodeCached, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
+        if (streamingRequest.Static && state.InputProtocol != MediaProtocol.File)
+        {
+            return new BadRequestObjectResult($"Input protocol {state.InputProtocol} cannot be streamed statically");
+        }
 
-            // Static stream
-            if (streamingRequest.Static)
-            {
-                var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
+        var outputPath = state.OutputFilePath;
+        var outputPathExists = File.Exists(outputPath);
 
-                if (state.MediaSource.IsInfiniteStream)
-                {
-                    var stream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper);
-                    return new FileStreamResult(stream, contentType);
-                }
+        var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
+        var isTranscodeCached = outputPathExists && transcodingJob is not null;
 
-                return FileStreamResponseHelpers.GetStaticFileResult(
-                    state.MediaPath,
-                    contentType);
+        StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, streamingRequest.Static || isTranscodeCached, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
+
+        // Static stream
+        if (streamingRequest.Static)
+        {
+            var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
+
+            if (state.MediaSource.IsInfiniteStream)
+            {
+                var stream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper);
+                return new FileStreamResult(stream, contentType);
             }
 
-            // Need to start ffmpeg (because media can't be returned directly)
-            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
-            var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath);
-            return await FileStreamResponseHelpers.GetTranscodedFile(
-                state,
-                isHeadRequest,
-                _httpContextAccessor.HttpContext,
-                _transcodingJobHelper,
-                ffmpegCommandLineArguments,
-                transcodingJobType,
-                cancellationTokenSource).ConfigureAwait(false);
+            return FileStreamResponseHelpers.GetStaticFileResult(
+                state.MediaPath,
+                contentType);
         }
+
+        // Need to start ffmpeg (because media can't be returned directly)
+        var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+        var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath);
+        return await FileStreamResponseHelpers.GetTranscodedFile(
+            state,
+            isHeadRequest,
+            _httpContextAccessor.HttpContext,
+            _transcodingJobHelper,
+            ffmpegCommandLineArguments,
+            transcodingJobType,
+            cancellationTokenSource).ConfigureAwait(false);
     }
 }

+ 581 - 582
Jellyfin.Api/Helpers/DynamicHlsHelper.cs

@@ -25,725 +25,724 @@ using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 using Microsoft.Net.Http.Headers;
 
-namespace Jellyfin.Api.Helpers
+namespace Jellyfin.Api.Helpers;
+
+/// <summary>
+/// Dynamic hls helper.
+/// </summary>
+public class DynamicHlsHelper
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IUserManager _userManager;
+    private readonly IDlnaManager _dlnaManager;
+    private readonly IMediaSourceManager _mediaSourceManager;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly IMediaEncoder _mediaEncoder;
+    private readonly IDeviceManager _deviceManager;
+    private readonly TranscodingJobHelper _transcodingJobHelper;
+    private readonly INetworkManager _networkManager;
+    private readonly ILogger<DynamicHlsHelper> _logger;
+    private readonly IHttpContextAccessor _httpContextAccessor;
+    private readonly EncodingHelper _encodingHelper;
+
     /// <summary>
-    /// Dynamic hls helper.
+    /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class.
     /// </summary>
-    public class DynamicHlsHelper
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+    /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+    /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param>
+    /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param>
+    /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+    /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
+    public DynamicHlsHelper(
+        ILibraryManager libraryManager,
+        IUserManager userManager,
+        IDlnaManager dlnaManager,
+        IMediaSourceManager mediaSourceManager,
+        IServerConfigurationManager serverConfigurationManager,
+        IMediaEncoder mediaEncoder,
+        IDeviceManager deviceManager,
+        TranscodingJobHelper transcodingJobHelper,
+        INetworkManager networkManager,
+        ILogger<DynamicHlsHelper> logger,
+        IHttpContextAccessor httpContextAccessor,
+        EncodingHelper encodingHelper)
+    {
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+        _dlnaManager = dlnaManager;
+        _mediaSourceManager = mediaSourceManager;
+        _serverConfigurationManager = serverConfigurationManager;
+        _mediaEncoder = mediaEncoder;
+        _deviceManager = deviceManager;
+        _transcodingJobHelper = transcodingJobHelper;
+        _networkManager = networkManager;
+        _logger = logger;
+        _httpContextAccessor = httpContextAccessor;
+        _encodingHelper = encodingHelper;
+    }
+
+    /// <summary>
+    /// Get master hls playlist.
+    /// </summary>
+    /// <param name="transcodingJobType">Transcoding job type.</param>
+    /// <param name="streamingRequest">Streaming request dto.</param>
+    /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
+    /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns>
+    public async Task<ActionResult> GetMasterHlsPlaylist(
+        TranscodingJobType transcodingJobType,
+        StreamingRequestDto streamingRequest,
+        bool enableAdaptiveBitrateStreaming)
+    {
+        var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head;
+        // CTS lifecycle is managed internally.
+        var cancellationTokenSource = new CancellationTokenSource();
+        return await GetMasterPlaylistInternal(
+            streamingRequest,
+            isHeadRequest,
+            enableAdaptiveBitrateStreaming,
+            transcodingJobType,
+            cancellationTokenSource).ConfigureAwait(false);
+    }
+
+    private async Task<ActionResult> GetMasterPlaylistInternal(
+        StreamingRequestDto streamingRequest,
+        bool isHeadRequest,
+        bool enableAdaptiveBitrateStreaming,
+        TranscodingJobType transcodingJobType,
+        CancellationTokenSource cancellationTokenSource)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-        private readonly IDlnaManager _dlnaManager;
-        private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly IMediaEncoder _mediaEncoder;
-        private readonly IDeviceManager _deviceManager;
-        private readonly TranscodingJobHelper _transcodingJobHelper;
-        private readonly INetworkManager _networkManager;
-        private readonly ILogger<DynamicHlsHelper> _logger;
-        private readonly IHttpContextAccessor _httpContextAccessor;
-        private readonly EncodingHelper _encodingHelper;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
-        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
-        /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param>
-        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
-        /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
-        public DynamicHlsHelper(
-            ILibraryManager libraryManager,
-            IUserManager userManager,
-            IDlnaManager dlnaManager,
-            IMediaSourceManager mediaSourceManager,
-            IServerConfigurationManager serverConfigurationManager,
-            IMediaEncoder mediaEncoder,
-            IDeviceManager deviceManager,
-            TranscodingJobHelper transcodingJobHelper,
-            INetworkManager networkManager,
-            ILogger<DynamicHlsHelper> logger,
-            IHttpContextAccessor httpContextAccessor,
-            EncodingHelper encodingHelper)
-        {
-            _libraryManager = libraryManager;
-            _userManager = userManager;
-            _dlnaManager = dlnaManager;
-            _mediaSourceManager = mediaSourceManager;
-            _serverConfigurationManager = serverConfigurationManager;
-            _mediaEncoder = mediaEncoder;
-            _deviceManager = deviceManager;
-            _transcodingJobHelper = transcodingJobHelper;
-            _networkManager = networkManager;
-            _logger = logger;
-            _httpContextAccessor = httpContextAccessor;
-            _encodingHelper = encodingHelper;
-        }
-
-        /// <summary>
-        /// Get master hls playlist.
-        /// </summary>
-        /// <param name="transcodingJobType">Transcoding job type.</param>
-        /// <param name="streamingRequest">Streaming request dto.</param>
-        /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
-        /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns>
-        public async Task<ActionResult> GetMasterHlsPlaylist(
-            TranscodingJobType transcodingJobType,
-            StreamingRequestDto streamingRequest,
-            bool enableAdaptiveBitrateStreaming)
-        {
-            var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head;
-            // CTS lifecycle is managed internally.
-            var cancellationTokenSource = new CancellationTokenSource();
-            return await GetMasterPlaylistInternal(
+        if (_httpContextAccessor.HttpContext is null)
+        {
+            throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext));
+        }
+
+        using var state = await StreamingHelpers.GetStreamingState(
                 streamingRequest,
-                isHeadRequest,
-                enableAdaptiveBitrateStreaming,
+                _httpContextAccessor.HttpContext,
+                _mediaSourceManager,
+                _userManager,
+                _libraryManager,
+                _serverConfigurationManager,
+                _mediaEncoder,
+                _encodingHelper,
+                _dlnaManager,
+                _deviceManager,
+                _transcodingJobHelper,
                 transcodingJobType,
-                cancellationTokenSource).ConfigureAwait(false);
-        }
+                cancellationTokenSource.Token)
+            .ConfigureAwait(false);
 
-        private async Task<ActionResult> GetMasterPlaylistInternal(
-            StreamingRequestDto streamingRequest,
-            bool isHeadRequest,
-            bool enableAdaptiveBitrateStreaming,
-            TranscodingJobType transcodingJobType,
-            CancellationTokenSource cancellationTokenSource)
+        _httpContextAccessor.HttpContext.Response.Headers.Add(HeaderNames.Expires, "0");
+        if (isHeadRequest)
         {
-            if (_httpContextAccessor.HttpContext is null)
-            {
-                throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext));
-            }
+            return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8"));
+        }
 
-            using var state = await StreamingHelpers.GetStreamingState(
-                    streamingRequest,
-                    _httpContextAccessor.HttpContext,
-                    _mediaSourceManager,
-                    _userManager,
-                    _libraryManager,
-                    _serverConfigurationManager,
-                    _mediaEncoder,
-                    _encodingHelper,
-                    _dlnaManager,
-                    _deviceManager,
-                    _transcodingJobHelper,
-                    transcodingJobType,
-                    cancellationTokenSource.Token)
-                .ConfigureAwait(false);
-
-            _httpContextAccessor.HttpContext.Response.Headers.Add(HeaderNames.Expires, "0");
-            if (isHeadRequest)
-            {
-                return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8"));
-            }
+        var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0);
 
-            var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0);
+        var builder = new StringBuilder();
 
-            var builder = new StringBuilder();
+        builder.AppendLine("#EXTM3U");
 
-            builder.AppendLine("#EXTM3U");
+        var isLiveStream = state.IsSegmentedLiveStream;
 
-            var isLiveStream = state.IsSegmentedLiveStream;
+        var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString();
 
-            var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString();
+        // from universal audio service
+        if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
+            && !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase))
+        {
+            queryString += "&SegmentContainer=" + state.Request.SegmentContainer;
+        }
 
-            // from universal audio service
-            if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
-                && !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase))
-            {
-                queryString += "&SegmentContainer=" + state.Request.SegmentContainer;
-            }
+        // from universal audio service
+        if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons)
+            && !queryString.Contains("TranscodeReasons=", StringComparison.OrdinalIgnoreCase))
+        {
+            queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
+        }
 
-            // from universal audio service
-            if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons)
-                && !queryString.Contains("TranscodeReasons=", StringComparison.OrdinalIgnoreCase))
-            {
-                queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
-            }
+        // Main stream
+        var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
 
-            // Main stream
-            var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
+        playlistUrl += queryString;
 
-            playlistUrl += queryString;
+        var subtitleStreams = state.MediaSource
+            .MediaStreams
+            .Where(i => i.IsTextSubtitleStream)
+            .ToList();
 
-            var subtitleStreams = state.MediaSource
-                .MediaStreams
-                .Where(i => i.IsTextSubtitleStream)
-                .ToList();
+        var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest)
+            ? "subs"
+            : null;
 
-            var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest)
-                ? "subs"
-                : null;
+        // If we're burning in subtitles then don't add additional subs to the manifest
+        if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
+        {
+            subtitleGroup = null;
+        }
 
-            // If we're burning in subtitles then don't add additional subs to the manifest
-            if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
-            {
-                subtitleGroup = null;
-            }
+        if (!string.IsNullOrWhiteSpace(subtitleGroup))
+        {
+            AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
+        }
+
+        var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
 
-            if (!string.IsNullOrWhiteSpace(subtitleGroup))
+        if (state.VideoStream is not null && state.VideoRequest is not null)
+        {
+            // Provide a workaround for the case issue between flac and fLaC.
+            var flacWaPlaylist = ApplyFlacCaseWorkaround(state, basicPlaylist.ToString());
+            if (!string.IsNullOrEmpty(flacWaPlaylist))
             {
-                AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
+                builder.Append(flacWaPlaylist);
             }
 
-            var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
+            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
 
-            if (state.VideoStream is not null && state.VideoRequest is not null)
+            // Provide SDR HEVC entrance for backward compatibility.
+            if (encodingOptions.AllowHevcEncoding
+                && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+                && !string.IsNullOrEmpty(state.VideoStream.VideoRange)
+                && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase)
+                && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
             {
-                // Provide a workaround for the case issue between flac and fLaC.
-                var flacWaPlaylist = ApplyFlacCaseWorkaround(state, basicPlaylist.ToString());
-                if (!string.IsNullOrEmpty(flacWaPlaylist))
-                {
-                    builder.Append(flacWaPlaylist);
-                }
-
-                var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
-
-                // Provide SDR HEVC entrance for backward compatibility.
-                if (encodingOptions.AllowHevcEncoding
-                    && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
-                    && !string.IsNullOrEmpty(state.VideoStream.VideoRange)
-                    && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase)
-                    && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
-                {
-                    var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
-                    if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
-                    {
-                        // Force HEVC Main Profile and disable video stream copy.
-                        state.OutputVideoCodec = "hevc";
-                        var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
-                        sdrVideoUrl += "&AllowVideoStreamCopy=false";
-
-                        var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
-                        var sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0;
-                        var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate;
-
-                        var sdrPlaylist = AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup);
-
-                        // Provide a workaround for the case issue between flac and fLaC.
-                        flacWaPlaylist = ApplyFlacCaseWorkaround(state, sdrPlaylist.ToString());
-                        if (!string.IsNullOrEmpty(flacWaPlaylist))
-                        {
-                            builder.Append(flacWaPlaylist);
-                        }
-
-                        // Restore the video codec
-                        state.OutputVideoCodec = "copy";
-                    }
-                }
-
-                // Provide Level 5.0 entrance for backward compatibility.
-                // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
-                // but in fact it is capable of playing videos up to Level 6.1.
-                if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
-                    && state.VideoStream.Level.HasValue
-                    && state.VideoStream.Level > 150
-                    && !string.IsNullOrEmpty(state.VideoStream.VideoRange)
-                    && string.Equals(state.VideoStream.VideoRange, "SDR", StringComparison.OrdinalIgnoreCase)
-                    && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+                var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
+                if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
                 {
-                    var playlistCodecsField = new StringBuilder();
-                    AppendPlaylistCodecsField(playlistCodecsField, state);
+                    // Force HEVC Main Profile and disable video stream copy.
+                    state.OutputVideoCodec = "hevc";
+                    var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
+                    sdrVideoUrl += "&AllowVideoStreamCopy=false";
 
-                    // Force the video level to 5.0.
-                    var originalLevel = state.VideoStream.Level;
-                    state.VideoStream.Level = 150;
-                    var newPlaylistCodecsField = new StringBuilder();
-                    AppendPlaylistCodecsField(newPlaylistCodecsField, state);
+                    var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
+                    var sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0;
+                    var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate;
 
-                    // Restore the video level.
-                    state.VideoStream.Level = originalLevel;
-                    var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField);
-                    builder.Append(newPlaylist);
+                    var sdrPlaylist = AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup);
 
                     // Provide a workaround for the case issue between flac and fLaC.
-                    flacWaPlaylist = ApplyFlacCaseWorkaround(state, newPlaylist);
+                    flacWaPlaylist = ApplyFlacCaseWorkaround(state, sdrPlaylist.ToString());
                     if (!string.IsNullOrEmpty(flacWaPlaylist))
                     {
                         builder.Append(flacWaPlaylist);
                     }
+
+                    // Restore the video codec
+                    state.OutputVideoCodec = "copy";
                 }
             }
 
-            if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp()))
-            {
-                var requestedVideoBitrate = state.VideoRequest is null ? 0 : state.VideoRequest.VideoBitRate ?? 0;
-
-                // By default, vary by just 200k
-                var variation = GetBitrateVariation(totalBitrate);
-
-                var newBitrate = totalBitrate - variation;
-                var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
-                AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
+            // Provide Level 5.0 entrance for backward compatibility.
+            // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
+            // but in fact it is capable of playing videos up to Level 6.1.
+            if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+                && state.VideoStream.Level.HasValue
+                && state.VideoStream.Level > 150
+                && !string.IsNullOrEmpty(state.VideoStream.VideoRange)
+                && string.Equals(state.VideoStream.VideoRange, "SDR", StringComparison.OrdinalIgnoreCase)
+                && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+            {
+                var playlistCodecsField = new StringBuilder();
+                AppendPlaylistCodecsField(playlistCodecsField, state);
+
+                // Force the video level to 5.0.
+                var originalLevel = state.VideoStream.Level;
+                state.VideoStream.Level = 150;
+                var newPlaylistCodecsField = new StringBuilder();
+                AppendPlaylistCodecsField(newPlaylistCodecsField, state);
+
+                // Restore the video level.
+                state.VideoStream.Level = originalLevel;
+                var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField);
+                builder.Append(newPlaylist);
 
-                variation *= 2;
-                newBitrate = totalBitrate - variation;
-                variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
-                AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
+                // Provide a workaround for the case issue between flac and fLaC.
+                flacWaPlaylist = ApplyFlacCaseWorkaround(state, newPlaylist);
+                if (!string.IsNullOrEmpty(flacWaPlaylist))
+                {
+                    builder.Append(flacWaPlaylist);
+                }
             }
-
-            return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
         }
 
-        private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
+        if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp()))
         {
-            var playlistBuilder = new StringBuilder();
-            playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
-                .Append(bitrate.ToString(CultureInfo.InvariantCulture))
-                .Append(",AVERAGE-BANDWIDTH=")
-                .Append(bitrate.ToString(CultureInfo.InvariantCulture));
+            var requestedVideoBitrate = state.VideoRequest is null ? 0 : state.VideoRequest.VideoBitRate ?? 0;
 
-            AppendPlaylistVideoRangeField(playlistBuilder, state);
+            // By default, vary by just 200k
+            var variation = GetBitrateVariation(totalBitrate);
 
-            AppendPlaylistCodecsField(playlistBuilder, state);
+            var newBitrate = totalBitrate - variation;
+            var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+            AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
 
-            AppendPlaylistResolutionField(playlistBuilder, state);
+            variation *= 2;
+            newBitrate = totalBitrate - variation;
+            variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+            AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
+        }
 
-            AppendPlaylistFramerateField(playlistBuilder, state);
+        return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
+    }
 
-            if (!string.IsNullOrWhiteSpace(subtitleGroup))
-            {
-                playlistBuilder.Append(",SUBTITLES=\"")
-                    .Append(subtitleGroup)
-                    .Append('"');
-            }
+    private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
+    {
+        var playlistBuilder = new StringBuilder();
+        playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
+            .Append(bitrate.ToString(CultureInfo.InvariantCulture))
+            .Append(",AVERAGE-BANDWIDTH=")
+            .Append(bitrate.ToString(CultureInfo.InvariantCulture));
 
-            playlistBuilder.Append(Environment.NewLine);
-            playlistBuilder.AppendLine(url);
-            builder.Append(playlistBuilder);
+        AppendPlaylistVideoRangeField(playlistBuilder, state);
 
-            return playlistBuilder;
+        AppendPlaylistCodecsField(playlistBuilder, state);
+
+        AppendPlaylistResolutionField(playlistBuilder, state);
+
+        AppendPlaylistFramerateField(playlistBuilder, state);
+
+        if (!string.IsNullOrWhiteSpace(subtitleGroup))
+        {
+            playlistBuilder.Append(",SUBTITLES=\"")
+                .Append(subtitleGroup)
+                .Append('"');
         }
 
-        /// <summary>
-        /// Appends a VIDEO-RANGE field containing the range of the output video stream.
-        /// </summary>
-        /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
-        /// <param name="builder">StringBuilder to append the field to.</param>
-        /// <param name="state">StreamState of the current stream.</param>
-        private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state)
+        playlistBuilder.Append(Environment.NewLine);
+        playlistBuilder.AppendLine(url);
+        builder.Append(playlistBuilder);
+
+        return playlistBuilder;
+    }
+
+    /// <summary>
+    /// Appends a VIDEO-RANGE field containing the range of the output video stream.
+    /// </summary>
+    /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+    /// <param name="builder">StringBuilder to append the field to.</param>
+    /// <param name="state">StreamState of the current stream.</param>
+    private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state)
+    {
+        if (state.VideoStream is not null && !string.IsNullOrEmpty(state.VideoStream.VideoRange))
         {
-            if (state.VideoStream is not null && !string.IsNullOrEmpty(state.VideoStream.VideoRange))
+            var videoRange = state.VideoStream.VideoRange;
+            if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
             {
-                var videoRange = state.VideoStream.VideoRange;
-                if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
-                {
-                    if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase))
-                    {
-                        builder.Append(",VIDEO-RANGE=SDR");
-                    }
-
-                    if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase))
-                    {
-                        builder.Append(",VIDEO-RANGE=PQ");
-                    }
-                }
-                else
+                if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase))
                 {
-                    // Currently we only encode to SDR.
                     builder.Append(",VIDEO-RANGE=SDR");
                 }
-            }
-        }
 
-        /// <summary>
-        /// Appends a CODECS field containing formatted strings of
-        /// the active streams output video and audio codecs.
-        /// </summary>
-        /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
-        /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
-        /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
-        /// <param name="builder">StringBuilder to append the field to.</param>
-        /// <param name="state">StreamState of the current stream.</param>
-        private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state)
-        {
-            // Video
-            string videoCodecs = string.Empty;
-            int? videoCodecLevel = GetOutputVideoCodecLevel(state);
-            if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue)
-            {
-                videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value);
+                if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase))
+                {
+                    builder.Append(",VIDEO-RANGE=PQ");
+                }
             }
-
-            // Audio
-            string audioCodecs = string.Empty;
-            if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec))
+            else
             {
-                audioCodecs = GetPlaylistAudioCodecs(state);
+                // Currently we only encode to SDR.
+                builder.Append(",VIDEO-RANGE=SDR");
             }
+        }
+    }
 
-            StringBuilder codecs = new StringBuilder();
+    /// <summary>
+    /// Appends a CODECS field containing formatted strings of
+    /// the active streams output video and audio codecs.
+    /// </summary>
+    /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+    /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
+    /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
+    /// <param name="builder">StringBuilder to append the field to.</param>
+    /// <param name="state">StreamState of the current stream.</param>
+    private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state)
+    {
+        // Video
+        string videoCodecs = string.Empty;
+        int? videoCodecLevel = GetOutputVideoCodecLevel(state);
+        if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue)
+        {
+            videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value);
+        }
 
-            codecs.Append(videoCodecs);
+        // Audio
+        string audioCodecs = string.Empty;
+        if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec))
+        {
+            audioCodecs = GetPlaylistAudioCodecs(state);
+        }
 
-            if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs))
-            {
-                codecs.Append(',');
-            }
+        StringBuilder codecs = new StringBuilder();
 
-            codecs.Append(audioCodecs);
+        codecs.Append(videoCodecs);
 
-            if (codecs.Length > 1)
-            {
-                builder.Append(",CODECS=\"")
-                    .Append(codecs)
-                    .Append('"');
-            }
+        if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs))
+        {
+            codecs.Append(',');
         }
 
-        /// <summary>
-        /// Appends a RESOLUTION field containing the resolution of the output stream.
-        /// </summary>
-        /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
-        /// <param name="builder">StringBuilder to append the field to.</param>
-        /// <param name="state">StreamState of the current stream.</param>
-        private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state)
+        codecs.Append(audioCodecs);
+
+        if (codecs.Length > 1)
         {
-            if (state.OutputWidth.HasValue && state.OutputHeight.HasValue)
-            {
-                builder.Append(",RESOLUTION=")
-                    .Append(state.OutputWidth.GetValueOrDefault())
-                    .Append('x')
-                    .Append(state.OutputHeight.GetValueOrDefault());
-            }
+            builder.Append(",CODECS=\"")
+                .Append(codecs)
+                .Append('"');
         }
+    }
 
-        /// <summary>
-        /// Appends a FRAME-RATE field containing the framerate of the output stream.
-        /// </summary>
-        /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
-        /// <param name="builder">StringBuilder to append the field to.</param>
-        /// <param name="state">StreamState of the current stream.</param>
-        private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state)
+    /// <summary>
+    /// Appends a RESOLUTION field containing the resolution of the output stream.
+    /// </summary>
+    /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+    /// <param name="builder">StringBuilder to append the field to.</param>
+    /// <param name="state">StreamState of the current stream.</param>
+    private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state)
+    {
+        if (state.OutputWidth.HasValue && state.OutputHeight.HasValue)
         {
-            double? framerate = null;
-            if (state.TargetFramerate.HasValue)
-            {
-                framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3);
-            }
-            else if (state.VideoStream?.RealFrameRate is not null)
-            {
-                framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3);
-            }
+            builder.Append(",RESOLUTION=")
+                .Append(state.OutputWidth.GetValueOrDefault())
+                .Append('x')
+                .Append(state.OutputHeight.GetValueOrDefault());
+        }
+    }
 
-            if (framerate.HasValue)
-            {
-                builder.Append(",FRAME-RATE=")
-                    .Append(framerate.Value.ToString(CultureInfo.InvariantCulture));
-            }
+    /// <summary>
+    /// Appends a FRAME-RATE field containing the framerate of the output stream.
+    /// </summary>
+    /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+    /// <param name="builder">StringBuilder to append the field to.</param>
+    /// <param name="state">StreamState of the current stream.</param>
+    private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state)
+    {
+        double? framerate = null;
+        if (state.TargetFramerate.HasValue)
+        {
+            framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3);
+        }
+        else if (state.VideoStream?.RealFrameRate is not null)
+        {
+            framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3);
         }
 
-        private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, IPAddress ipAddress)
+        if (framerate.HasValue)
         {
-            // Within the local network this will likely do more harm than good.
-            if (_networkManager.IsInLocalNetwork(ipAddress))
-            {
-                return false;
-            }
+            builder.Append(",FRAME-RATE=")
+                .Append(framerate.Value.ToString(CultureInfo.InvariantCulture));
+        }
+    }
 
-            if (!enableAdaptiveBitrateStreaming)
-            {
-                return false;
-            }
+    private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, IPAddress ipAddress)
+    {
+        // Within the local network this will likely do more harm than good.
+        if (_networkManager.IsInLocalNetwork(ipAddress))
+        {
+            return false;
+        }
 
-            if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath))
-            {
-                // Opening live streams is so slow it's not even worth it
-                return false;
-            }
+        if (!enableAdaptiveBitrateStreaming)
+        {
+            return false;
+        }
 
-            if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
-            {
-                return false;
-            }
+        if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath))
+        {
+            // Opening live streams is so slow it's not even worth it
+            return false;
+        }
 
-            if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec))
-            {
-                return false;
-            }
+        if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+        {
+            return false;
+        }
 
-            if (!state.IsOutputVideo)
-            {
-                return false;
-            }
+        if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec))
+        {
+            return false;
+        }
 
-            // Having problems in android
+        if (!state.IsOutputVideo)
+        {
             return false;
-            // return state.VideoRequest.VideoBitRate.HasValue;
         }
 
-        private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder, ClaimsPrincipal user)
+        // Having problems in android
+        return false;
+        // return state.VideoRequest.VideoBitRate.HasValue;
+    }
+
+    private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder, ClaimsPrincipal user)
+    {
+        if (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Drop)
         {
-            if (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Drop)
-            {
-                return;
-            }
+            return;
+        }
 
-            var selectedIndex = state.SubtitleStream is null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index;
-            const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\"";
+        var selectedIndex = state.SubtitleStream is null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index;
+        const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\"";
 
-            foreach (var stream in subtitles)
-            {
-                var name = stream.DisplayTitle;
-
-                var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index;
-                var isForced = stream.IsForced;
-
-                var url = string.Format(
-                    CultureInfo.InvariantCulture,
-                    "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}",
-                    state.Request.MediaSourceId,
-                    stream.Index.ToString(CultureInfo.InvariantCulture),
-                    30.ToString(CultureInfo.InvariantCulture),
-                    user.GetToken());
-
-                var line = string.Format(
-                    CultureInfo.InvariantCulture,
-                    Format,
-                    name,
-                    isDefault ? "YES" : "NO",
-                    isForced ? "YES" : "NO",
-                    url,
-                    stream.Language ?? "Unknown");
-
-                builder.AppendLine(line);
-            }
+        foreach (var stream in subtitles)
+        {
+            var name = stream.DisplayTitle;
+
+            var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index;
+            var isForced = stream.IsForced;
+
+            var url = string.Format(
+                CultureInfo.InvariantCulture,
+                "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}",
+                state.Request.MediaSourceId,
+                stream.Index.ToString(CultureInfo.InvariantCulture),
+                30.ToString(CultureInfo.InvariantCulture),
+                user.GetToken());
+
+            var line = string.Format(
+                CultureInfo.InvariantCulture,
+                Format,
+                name,
+                isDefault ? "YES" : "NO",
+                isForced ? "YES" : "NO",
+                url,
+                stream.Language ?? "Unknown");
+
+            builder.AppendLine(line);
         }
+    }
 
-        /// <summary>
-        /// Get the H.26X level of the output video stream.
-        /// </summary>
-        /// <param name="state">StreamState of the current stream.</param>
-        /// <returns>H.26X level of the output video stream.</returns>
-        private int? GetOutputVideoCodecLevel(StreamState state)
+    /// <summary>
+    /// Get the H.26X level of the output video stream.
+    /// </summary>
+    /// <param name="state">StreamState of the current stream.</param>
+    /// <returns>H.26X level of the output video stream.</returns>
+    private int? GetOutputVideoCodecLevel(StreamState state)
+    {
+        string levelString = string.Empty;
+        if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+            && state.VideoStream is not null
+            && state.VideoStream.Level.HasValue)
         {
-            string levelString = string.Empty;
-            if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
-                && state.VideoStream is not null
-                && state.VideoStream.Level.HasValue)
+            levelString = state.VideoStream.Level.ToString() ?? string.Empty;
+        }
+        else
+        {
+            if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
             {
-                levelString = state.VideoStream.Level.ToString() ?? string.Empty;
+                levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41";
+                levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
             }
-            else
-            {
-                if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
-                {
-                    levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41";
-                    levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
-                }
 
-                if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
-                {
-                    levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120";
-                    levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
-                }
-            }
-
-            if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
+            if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
             {
-                return parsedLevel;
+                levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120";
+                levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
             }
-
-            return null;
         }
 
-        /// <summary>
-        /// Get the H.26X profile of the output video stream.
-        /// </summary>
-        /// <param name="state">StreamState of the current stream.</param>
-        /// <param name="codec">Video codec.</param>
-        /// <returns>H.26X profile of the output video stream.</returns>
-        private string GetOutputVideoCodecProfile(StreamState state, string codec)
+        if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
         {
-            string profileString = string.Empty;
-            if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
-                && !string.IsNullOrEmpty(state.VideoStream.Profile))
-            {
-                profileString = state.VideoStream.Profile;
-            }
-            else if (!string.IsNullOrEmpty(codec))
-            {
-                profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty;
-                if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
-                {
-                    profileString ??= "high";
-                }
+            return parsedLevel;
+        }
 
-                if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
-                {
-                    profileString ??= "main";
-                }
-            }
+        return null;
+    }
 
-            return profileString;
+    /// <summary>
+    /// Get the H.26X profile of the output video stream.
+    /// </summary>
+    /// <param name="state">StreamState of the current stream.</param>
+    /// <param name="codec">Video codec.</param>
+    /// <returns>H.26X profile of the output video stream.</returns>
+    private string GetOutputVideoCodecProfile(StreamState state, string codec)
+    {
+        string profileString = string.Empty;
+        if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+            && !string.IsNullOrEmpty(state.VideoStream.Profile))
+        {
+            profileString = state.VideoStream.Profile;
         }
-
-        /// <summary>
-        /// Gets a formatted string of the output audio codec, for use in the CODECS field.
-        /// </summary>
-        /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
-        /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
-        /// <param name="state">StreamState of the current stream.</param>
-        /// <returns>Formatted audio codec string.</returns>
-        private string GetPlaylistAudioCodecs(StreamState state)
+        else if (!string.IsNullOrEmpty(codec))
         {
-            if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase))
+            profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty;
+            if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
             {
-                string? profile = state.GetRequestedProfiles("aac").FirstOrDefault();
-                return HlsCodecStringHelpers.GetAACString(profile);
+                profileString ??= "high";
             }
 
-            if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase))
+            if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
             {
-                return HlsCodecStringHelpers.GetMP3String();
+                profileString ??= "main";
             }
+        }
 
-            if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
-            {
-                return HlsCodecStringHelpers.GetAC3String();
-            }
+        return profileString;
+    }
 
-            if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
-            {
-                return HlsCodecStringHelpers.GetEAC3String();
-            }
+    /// <summary>
+    /// Gets a formatted string of the output audio codec, for use in the CODECS field.
+    /// </summary>
+    /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
+    /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
+    /// <param name="state">StreamState of the current stream.</param>
+    /// <returns>Formatted audio codec string.</returns>
+    private string GetPlaylistAudioCodecs(StreamState state)
+    {
+        if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase))
+        {
+            string? profile = state.GetRequestedProfiles("aac").FirstOrDefault();
+            return HlsCodecStringHelpers.GetAACString(profile);
+        }
 
-            if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase))
-            {
-                return HlsCodecStringHelpers.GetFLACString();
-            }
+        if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase))
+        {
+            return HlsCodecStringHelpers.GetMP3String();
+        }
 
-            if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase))
-            {
-                return HlsCodecStringHelpers.GetALACString();
-            }
+        if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
+        {
+            return HlsCodecStringHelpers.GetAC3String();
+        }
 
-            if (string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase))
-            {
-                return HlsCodecStringHelpers.GetOPUSString();
-            }
+        if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
+        {
+            return HlsCodecStringHelpers.GetEAC3String();
+        }
 
-            return string.Empty;
+        if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase))
+        {
+            return HlsCodecStringHelpers.GetFLACString();
         }
 
-        /// <summary>
-        /// Gets a formatted string of the output video codec, for use in the CODECS field.
-        /// </summary>
-        /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
-        /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
-        /// <param name="state">StreamState of the current stream.</param>
-        /// <param name="codec">Video codec.</param>
-        /// <param name="level">Video level.</param>
-        /// <returns>Formatted video codec string.</returns>
-        private string GetPlaylistVideoCodecs(StreamState state, string codec, int level)
+        if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase))
         {
-            if (level == 0)
-            {
-                // This is 0 when there's no requested H.26X level in the device profile
-                // and the source is not encoded in H.26X
-                _logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist");
-                return string.Empty;
-            }
+            return HlsCodecStringHelpers.GetALACString();
+        }
 
-            if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase))
-            {
-                string profile = GetOutputVideoCodecProfile(state, "h264");
-                return HlsCodecStringHelpers.GetH264String(profile, level);
-            }
+        if (string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase))
+        {
+            return HlsCodecStringHelpers.GetOPUSString();
+        }
 
-            if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
-            {
-                string profile = GetOutputVideoCodecProfile(state, "hevc");
-                return HlsCodecStringHelpers.GetH265String(profile, level);
-            }
+        return string.Empty;
+    }
 
+    /// <summary>
+    /// Gets a formatted string of the output video codec, for use in the CODECS field.
+    /// </summary>
+    /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
+    /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
+    /// <param name="state">StreamState of the current stream.</param>
+    /// <param name="codec">Video codec.</param>
+    /// <param name="level">Video level.</param>
+    /// <returns>Formatted video codec string.</returns>
+    private string GetPlaylistVideoCodecs(StreamState state, string codec, int level)
+    {
+        if (level == 0)
+        {
+            // This is 0 when there's no requested H.26X level in the device profile
+            // and the source is not encoded in H.26X
+            _logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist");
             return string.Empty;
         }
 
-        private int GetBitrateVariation(int bitrate)
+        if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase))
         {
-            // By default, vary by just 50k
-            var variation = 50000;
-
-            if (bitrate >= 10000000)
-            {
-                variation = 2000000;
-            }
-            else if (bitrate >= 5000000)
-            {
-                variation = 1500000;
-            }
-            else if (bitrate >= 3000000)
-            {
-                variation = 1000000;
-            }
-            else if (bitrate >= 2000000)
-            {
-                variation = 500000;
-            }
-            else if (bitrate >= 1000000)
-            {
-                variation = 300000;
-            }
-            else if (bitrate >= 600000)
-            {
-                variation = 200000;
-            }
-            else if (bitrate >= 400000)
-            {
-                variation = 100000;
-            }
-
-            return variation;
+            string profile = GetOutputVideoCodecProfile(state, "h264");
+            return HlsCodecStringHelpers.GetH264String(profile, level);
         }
 
-        private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
+        if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
         {
-            return url.Replace(
-                "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
-                "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
-                StringComparison.OrdinalIgnoreCase);
+            string profile = GetOutputVideoCodecProfile(state, "hevc");
+            return HlsCodecStringHelpers.GetH265String(profile, level);
         }
 
-        private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
+        return string.Empty;
+    }
+
+    private int GetBitrateVariation(int bitrate)
+    {
+        // By default, vary by just 50k
+        var variation = 50000;
+
+        if (bitrate >= 10000000)
         {
-            string profileStr = codec + "-profile=";
-            return url.Replace(
-                profileStr + oldValue,
-                profileStr + newValue,
-                StringComparison.OrdinalIgnoreCase);
+            variation = 2000000;
         }
-
-        private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
+        else if (bitrate >= 5000000)
         {
-            var oldPlaylist = playlist.ToString();
-            return oldPlaylist.Replace(
-                oldValue.ToString(),
-                newValue.ToString(),
-                StringComparison.Ordinal);
+            variation = 1500000;
         }
-
-        private string ApplyFlacCaseWorkaround(StreamState state, string srcPlaylist)
+        else if (bitrate >= 3000000)
         {
-            if (!string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase))
-            {
-                return string.Empty;
-            }
+            variation = 1000000;
+        }
+        else if (bitrate >= 2000000)
+        {
+            variation = 500000;
+        }
+        else if (bitrate >= 1000000)
+        {
+            variation = 300000;
+        }
+        else if (bitrate >= 600000)
+        {
+            variation = 200000;
+        }
+        else if (bitrate >= 400000)
+        {
+            variation = 100000;
+        }
 
-            var newPlaylist = srcPlaylist.Replace(",flac\"", ",fLaC\"", StringComparison.Ordinal);
+        return variation;
+    }
+
+    private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
+    {
+        return url.Replace(
+            "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
+            "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
+            StringComparison.OrdinalIgnoreCase);
+    }
+
+    private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
+    {
+        string profileStr = codec + "-profile=";
+        return url.Replace(
+            profileStr + oldValue,
+            profileStr + newValue,
+            StringComparison.OrdinalIgnoreCase);
+    }
 
-            return newPlaylist.Contains(",fLaC\"", StringComparison.Ordinal) ? newPlaylist : string.Empty;
+    private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
+    {
+        var oldPlaylist = playlist.ToString();
+        return oldPlaylist.Replace(
+            oldValue.ToString(),
+            newValue.ToString(),
+            StringComparison.Ordinal);
+    }
+
+    private string ApplyFlacCaseWorkaround(StreamState state, string srcPlaylist)
+    {
+        if (!string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase))
+        {
+            return string.Empty;
         }
+
+        var newPlaylist = srcPlaylist.Replace(",flac\"", ",fLaC\"", StringComparison.Ordinal);
+
+        return newPlaylist.Contains(",fLaC\"", StringComparison.Ordinal) ? newPlaylist : string.Empty;
     }
 }

+ 85 - 86
Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs

@@ -11,110 +11,109 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Net.Http.Headers;
 
-namespace Jellyfin.Api.Helpers
+namespace Jellyfin.Api.Helpers;
+
+/// <summary>
+/// The stream response helpers.
+/// </summary>
+public static class FileStreamResponseHelpers
 {
     /// <summary>
-    /// The stream response helpers.
+    /// Returns a static file from a remote source.
     /// </summary>
-    public static class FileStreamResponseHelpers
+    /// <param name="state">The current <see cref="StreamState"/>.</param>
+    /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param>
+    /// <param name="httpContext">The current http context.</param>
+    /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
+    /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns>
+    public static async Task<ActionResult> GetStaticRemoteStreamResult(
+        StreamState state,
+        HttpClient httpClient,
+        HttpContext httpContext,
+        CancellationToken cancellationToken = default)
     {
-        /// <summary>
-        /// Returns a static file from a remote source.
-        /// </summary>
-        /// <param name="state">The current <see cref="StreamState"/>.</param>
-        /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param>
-        /// <param name="httpContext">The current http context.</param>
-        /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
-        /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns>
-        public static async Task<ActionResult> GetStaticRemoteStreamResult(
-            StreamState state,
-            HttpClient httpClient,
-            HttpContext httpContext,
-            CancellationToken cancellationToken = default)
+        if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
         {
-            if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
-            {
-                httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent);
-            }
+            httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent);
+        }
 
-            // Can't dispose the response as it's required up the call chain.
-            var response = await httpClient.GetAsync(new Uri(state.MediaPath), cancellationToken).ConfigureAwait(false);
-            var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain;
+        // Can't dispose the response as it's required up the call chain.
+        var response = await httpClient.GetAsync(new Uri(state.MediaPath), cancellationToken).ConfigureAwait(false);
+        var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain;
 
-            httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
+        httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
 
-            return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType);
-        }
+        return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType);
+    }
 
-        /// <summary>
-        /// Returns a static file from the server.
-        /// </summary>
-        /// <param name="path">The path to the file.</param>
-        /// <param name="contentType">The content type of the file.</param>
-        /// <returns>An <see cref="ActionResult"/> the file.</returns>
-        public static ActionResult GetStaticFileResult(
-            string path,
-            string contentType)
-        {
-            return new PhysicalFileResult(path, contentType) { EnableRangeProcessing = true };
-        }
+    /// <summary>
+    /// Returns a static file from the server.
+    /// </summary>
+    /// <param name="path">The path to the file.</param>
+    /// <param name="contentType">The content type of the file.</param>
+    /// <returns>An <see cref="ActionResult"/> the file.</returns>
+    public static ActionResult GetStaticFileResult(
+        string path,
+        string contentType)
+    {
+        return new PhysicalFileResult(path, contentType) { EnableRangeProcessing = true };
+    }
 
-        /// <summary>
-        /// Returns a transcoded file from the server.
-        /// </summary>
-        /// <param name="state">The current <see cref="StreamState"/>.</param>
-        /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
-        /// <param name="httpContext">The current http context.</param>
-        /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param>
-        /// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param>
-        /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
-        /// <param name="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param>
-        /// <returns>A <see cref="Task{ActionResult}"/> containing the transcoded file.</returns>
-        public static async Task<ActionResult> GetTranscodedFile(
-            StreamState state,
-            bool isHeadRequest,
-            HttpContext httpContext,
-            TranscodingJobHelper transcodingJobHelper,
-            string ffmpegCommandLineArguments,
-            TranscodingJobType transcodingJobType,
-            CancellationTokenSource cancellationTokenSource)
-        {
-            // Use the command line args with a dummy playlist path
-            var outputPath = state.OutputFilePath;
+    /// <summary>
+    /// Returns a transcoded file from the server.
+    /// </summary>
+    /// <param name="state">The current <see cref="StreamState"/>.</param>
+    /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
+    /// <param name="httpContext">The current http context.</param>
+    /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param>
+    /// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param>
+    /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
+    /// <param name="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param>
+    /// <returns>A <see cref="Task{ActionResult}"/> containing the transcoded file.</returns>
+    public static async Task<ActionResult> GetTranscodedFile(
+        StreamState state,
+        bool isHeadRequest,
+        HttpContext httpContext,
+        TranscodingJobHelper transcodingJobHelper,
+        string ffmpegCommandLineArguments,
+        TranscodingJobType transcodingJobType,
+        CancellationTokenSource cancellationTokenSource)
+    {
+        // Use the command line args with a dummy playlist path
+        var outputPath = state.OutputFilePath;
 
-            httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
+        httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
 
-            var contentType = state.GetMimeType(outputPath);
+        var contentType = state.GetMimeType(outputPath);
 
-            // Headers only
-            if (isHeadRequest)
-            {
-                httpContext.Response.Headers[HeaderNames.ContentType] = contentType;
-                return new OkResult();
-            }
+        // Headers only
+        if (isHeadRequest)
+        {
+            httpContext.Response.Headers[HeaderNames.ContentType] = contentType;
+            return new OkResult();
+        }
 
-            var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath);
-            await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
-            try
+        var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath);
+        await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
+        try
+        {
+            TranscodingJobDto? job;
+            if (!File.Exists(outputPath))
             {
-                TranscodingJobDto? job;
-                if (!File.Exists(outputPath))
-                {
-                    job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, httpContext.Request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false);
-                }
-                else
-                {
-                    job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive);
-                    state.Dispose();
-                }
-
-                var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper);
-                return new FileStreamResult(stream, contentType);
+                job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, httpContext.Request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false);
             }
-            finally
+            else
             {
-                transcodingLock.Release();
+                job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive);
+                state.Dispose();
             }
+
+            var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper);
+            return new FileStreamResult(stream, contentType);
+        }
+        finally
+        {
+            transcodingLock.Release();
         }
     }
 }

+ 153 - 154
Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs

@@ -2,182 +2,181 @@
 using System.Globalization;
 using System.Text;
 
-namespace Jellyfin.Api.Helpers
+namespace Jellyfin.Api.Helpers;
+
+/// <summary>
+/// Hls Codec string helpers.
+/// </summary>
+public static class HlsCodecStringHelpers
 {
     /// <summary>
-    /// Hls Codec string helpers.
+    /// Codec name for MP3.
+    /// </summary>
+    public const string MP3 = "mp4a.40.34";
+
+    /// <summary>
+    /// Codec name for AC-3.
+    /// </summary>
+    public const string AC3 = "mp4a.a5";
+
+    /// <summary>
+    /// Codec name for E-AC-3.
+    /// </summary>
+    public const string EAC3 = "mp4a.a6";
+
+    /// <summary>
+    /// Codec name for FLAC.
+    /// </summary>
+    public const string FLAC = "flac";
+
+    /// <summary>
+    /// Codec name for ALAC.
+    /// </summary>
+    public const string ALAC = "alac";
+
+    /// <summary>
+    /// Codec name for OPUS.
+    /// </summary>
+    public const string OPUS = "opus";
+
+    /// <summary>
+    /// Gets a MP3 codec string.
     /// </summary>
-    public static class HlsCodecStringHelpers
+    /// <returns>MP3 codec string.</returns>
+    public static string GetMP3String()
     {
-        /// <summary>
-        /// Codec name for MP3.
-        /// </summary>
-        public const string MP3 = "mp4a.40.34";
-
-        /// <summary>
-        /// Codec name for AC-3.
-        /// </summary>
-        public const string AC3 = "mp4a.a5";
-
-        /// <summary>
-        /// Codec name for E-AC-3.
-        /// </summary>
-        public const string EAC3 = "mp4a.a6";
-
-        /// <summary>
-        /// Codec name for FLAC.
-        /// </summary>
-        public const string FLAC = "flac";
-
-        /// <summary>
-        /// Codec name for ALAC.
-        /// </summary>
-        public const string ALAC = "alac";
-
-        /// <summary>
-        /// Codec name for OPUS.
-        /// </summary>
-        public const string OPUS = "opus";
-
-        /// <summary>
-        /// Gets a MP3 codec string.
-        /// </summary>
-        /// <returns>MP3 codec string.</returns>
-        public static string GetMP3String()
-        {
-            return MP3;
-        }
+        return MP3;
+    }
 
-        /// <summary>
-        /// Gets an AAC codec string.
-        /// </summary>
-        /// <param name="profile">AAC profile.</param>
-        /// <returns>AAC codec string.</returns>
-        public static string GetAACString(string? profile)
+    /// <summary>
+    /// Gets an AAC codec string.
+    /// </summary>
+    /// <param name="profile">AAC profile.</param>
+    /// <returns>AAC codec string.</returns>
+    public static string GetAACString(string? profile)
+    {
+        StringBuilder result = new StringBuilder("mp4a", 9);
+
+        if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase))
         {
-            StringBuilder result = new StringBuilder("mp4a", 9);
-
-            if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase))
-            {
-                result.Append(".40.5");
-            }
-            else
-            {
-                // Default to LC if profile is invalid
-                result.Append(".40.2");
-            }
-
-            return result.ToString();
+            result.Append(".40.5");
         }
-
-        /// <summary>
-        /// Gets an AC-3 codec string.
-        /// </summary>
-        /// <returns>AC-3 codec string.</returns>
-        public static string GetAC3String()
+        else
         {
-            return AC3;
+            // Default to LC if profile is invalid
+            result.Append(".40.2");
         }
 
-        /// <summary>
-        /// Gets an E-AC-3 codec string.
-        /// </summary>
-        /// <returns>E-AC-3 codec string.</returns>
-        public static string GetEAC3String()
+        return result.ToString();
+    }
+
+    /// <summary>
+    /// Gets an AC-3 codec string.
+    /// </summary>
+    /// <returns>AC-3 codec string.</returns>
+    public static string GetAC3String()
+    {
+        return AC3;
+    }
+
+    /// <summary>
+    /// Gets an E-AC-3 codec string.
+    /// </summary>
+    /// <returns>E-AC-3 codec string.</returns>
+    public static string GetEAC3String()
+    {
+        return EAC3;
+    }
+
+    /// <summary>
+    /// Gets an FLAC codec string.
+    /// </summary>
+    /// <returns>FLAC codec string.</returns>
+    public static string GetFLACString()
+    {
+        return FLAC;
+    }
+
+    /// <summary>
+    /// Gets an ALAC codec string.
+    /// </summary>
+    /// <returns>ALAC codec string.</returns>
+    public static string GetALACString()
+    {
+        return ALAC;
+    }
+
+    /// <summary>
+    /// Gets an OPUS codec string.
+    /// </summary>
+    /// <returns>OPUS codec string.</returns>
+    public static string GetOPUSString()
+    {
+        return OPUS;
+    }
+
+    /// <summary>
+    /// Gets a H.264 codec string.
+    /// </summary>
+    /// <param name="profile">H.264 profile.</param>
+    /// <param name="level">H.264 level.</param>
+    /// <returns>H.264 string.</returns>
+    public static string GetH264String(string? profile, int level)
+    {
+        StringBuilder result = new StringBuilder("avc1", 11);
+
+        if (string.Equals(profile, "high", StringComparison.OrdinalIgnoreCase))
         {
-            return EAC3;
+            result.Append(".6400");
         }
-
-        /// <summary>
-        /// Gets an FLAC codec string.
-        /// </summary>
-        /// <returns>FLAC codec string.</returns>
-        public static string GetFLACString()
+        else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase))
         {
-            return FLAC;
+            result.Append(".4D40");
         }
-
-        /// <summary>
-        /// Gets an ALAC codec string.
-        /// </summary>
-        /// <returns>ALAC codec string.</returns>
-        public static string GetALACString()
+        else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase))
         {
-            return ALAC;
+            result.Append(".42E0");
         }
-
-        /// <summary>
-        /// Gets an OPUS codec string.
-        /// </summary>
-        /// <returns>OPUS codec string.</returns>
-        public static string GetOPUSString()
+        else
         {
-            return OPUS;
+            // Default to constrained baseline if profile is invalid
+            result.Append(".4240");
         }
 
-        /// <summary>
-        /// Gets a H.264 codec string.
-        /// </summary>
-        /// <param name="profile">H.264 profile.</param>
-        /// <param name="level">H.264 level.</param>
-        /// <returns>H.264 string.</returns>
-        public static string GetH264String(string? profile, int level)
+        string levelHex = level.ToString("X2", CultureInfo.InvariantCulture);
+        result.Append(levelHex);
+
+        return result.ToString();
+    }
+
+    /// <summary>
+    /// Gets a H.265 codec string.
+    /// </summary>
+    /// <param name="profile">H.265 profile.</param>
+    /// <param name="level">H.265 level.</param>
+    /// <returns>H.265 string.</returns>
+    public static string GetH265String(string? profile, int level)
+    {
+        // The h265 syntax is a bit of a mystery at the time this comment was written.
+        // This is what I've found through various sources:
+        // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
+        StringBuilder result = new StringBuilder("hvc1", 16);
+
+        if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase))
         {
-            StringBuilder result = new StringBuilder("avc1", 11);
-
-            if (string.Equals(profile, "high", StringComparison.OrdinalIgnoreCase))
-            {
-                result.Append(".6400");
-            }
-            else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase))
-            {
-                result.Append(".4D40");
-            }
-            else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase))
-            {
-                result.Append(".42E0");
-            }
-            else
-            {
-                // Default to constrained baseline if profile is invalid
-                result.Append(".4240");
-            }
-
-            string levelHex = level.ToString("X2", CultureInfo.InvariantCulture);
-            result.Append(levelHex);
-
-            return result.ToString();
+            result.Append(".2.4");
         }
-
-        /// <summary>
-        /// Gets a H.265 codec string.
-        /// </summary>
-        /// <param name="profile">H.265 profile.</param>
-        /// <param name="level">H.265 level.</param>
-        /// <returns>H.265 string.</returns>
-        public static string GetH265String(string? profile, int level)
+        else
         {
-            // The h265 syntax is a bit of a mystery at the time this comment was written.
-            // This is what I've found through various sources:
-            // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
-            StringBuilder result = new StringBuilder("hvc1", 16);
-
-            if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase))
-            {
-                result.Append(".2.4");
-            }
-            else
-            {
-                // Default to main if profile is invalid
-                result.Append(".1.4");
-            }
-
-            result.Append(".L")
-                .Append(level)
-                .Append(".B0");
-
-            return result.ToString();
+            // Default to main if profile is invalid
+            result.Append(".1.4");
         }
+
+        result.Append(".L")
+            .Append(level)
+            .Append(".B0");
+
+        return result.ToString();
     }
 }

+ 96 - 97
Jellyfin.Api/Helpers/HlsHelpers.cs

@@ -8,131 +8,130 @@ using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Model.IO;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Helpers
+namespace Jellyfin.Api.Helpers;
+
+/// <summary>
+/// The hls helpers.
+/// </summary>
+public static class HlsHelpers
 {
     /// <summary>
-    /// The hls helpers.
+    /// Waits for a minimum number of segments to be available.
     /// </summary>
-    public static class HlsHelpers
+    /// <param name="playlist">The playlist string.</param>
+    /// <param name="segmentCount">The segment count.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
+    /// <returns>A <see cref="Task"/> indicating the waiting process.</returns>
+    public static async Task WaitForMinimumSegmentCount(string playlist, int? segmentCount, ILogger logger, CancellationToken cancellationToken)
     {
-        /// <summary>
-        /// Waits for a minimum number of segments to be available.
-        /// </summary>
-        /// <param name="playlist">The playlist string.</param>
-        /// <param name="segmentCount">The segment count.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
-        /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
-        /// <returns>A <see cref="Task"/> indicating the waiting process.</returns>
-        public static async Task WaitForMinimumSegmentCount(string playlist, int? segmentCount, ILogger logger, CancellationToken cancellationToken)
-        {
-            logger.LogDebug("Waiting for {0} segments in {1}", segmentCount, playlist);
+        logger.LogDebug("Waiting for {0} segments in {1}", segmentCount, playlist);
 
-            while (!cancellationToken.IsCancellationRequested)
+        while (!cancellationToken.IsCancellationRequested)
+        {
+            try
             {
-                try
+                // Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written
+                var fileStream = new FileStream(
+                    playlist,
+                    FileMode.Open,
+                    FileAccess.Read,
+                    FileShare.ReadWrite,
+                    IODefaults.FileStreamBufferSize,
+                    FileOptions.Asynchronous | FileOptions.SequentialScan);
+                await using (fileStream.ConfigureAwait(false))
                 {
-                    // Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written
-                    var fileStream = new FileStream(
-                        playlist,
-                        FileMode.Open,
-                        FileAccess.Read,
-                        FileShare.ReadWrite,
-                        IODefaults.FileStreamBufferSize,
-                        FileOptions.Asynchronous | FileOptions.SequentialScan);
-                    await using (fileStream.ConfigureAwait(false))
-                    {
-                        using var reader = new StreamReader(fileStream);
-                        var count = 0;
+                    using var reader = new StreamReader(fileStream);
+                    var count = 0;
 
-                        while (!reader.EndOfStream)
+                    while (!reader.EndOfStream)
+                    {
+                        var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
+                        if (line is null)
                         {
-                            var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
-                            if (line is null)
-                            {
-                                // Nothing currently in buffer.
-                                break;
-                            }
+                            // Nothing currently in buffer.
+                            break;
+                        }
 
-                            if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1)
+                        if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1)
+                        {
+                            count++;
+                            if (count >= segmentCount)
                             {
-                                count++;
-                                if (count >= segmentCount)
-                                {
-                                    logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist);
-                                    return;
-                                }
+                                logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist);
+                                return;
                             }
                         }
                     }
-
-                    await Task.Delay(100, cancellationToken).ConfigureAwait(false);
-                }
-                catch (IOException)
-                {
-                    // May get an error if the file is locked
                 }
 
-                await Task.Delay(50, cancellationToken).ConfigureAwait(false);
+                await Task.Delay(100, cancellationToken).ConfigureAwait(false);
             }
-        }
-
-        /// <summary>
-        /// Gets the #EXT-X-MAP string.
-        /// </summary>
-        /// <param name="outputPath">The output path of the file.</param>
-        /// <param name="state">The <see cref="StreamState"/>.</param>
-        /// <param name="isOsDepends">Get a normal string or depends on OS.</param>
-        /// <returns>The string text of #EXT-X-MAP.</returns>
-        public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends)
-        {
-            var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
-            var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
-            var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
-            var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
-
-            // on Linux/Unix
-            // #EXT-X-MAP:URI="prefix-1.mp4"
-            var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension;
-            if (!isOsDepends)
+            catch (IOException)
             {
-                return fmp4InitFileName;
+                // May get an error if the file is locked
             }
 
-            if (OperatingSystem.IsWindows())
-            {
-                // on Windows
-                // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4"
-                fmp4InitFileName = outputPrefix + "-1" + outputExtension;
-            }
+            await Task.Delay(50, cancellationToken).ConfigureAwait(false);
+        }
+    }
 
+    /// <summary>
+    /// Gets the #EXT-X-MAP string.
+    /// </summary>
+    /// <param name="outputPath">The output path of the file.</param>
+    /// <param name="state">The <see cref="StreamState"/>.</param>
+    /// <param name="isOsDepends">Get a normal string or depends on OS.</param>
+    /// <returns>The string text of #EXT-X-MAP.</returns>
+    public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends)
+    {
+        var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
+        var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+        var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
+        var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
+
+        // on Linux/Unix
+        // #EXT-X-MAP:URI="prefix-1.mp4"
+        var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension;
+        if (!isOsDepends)
+        {
             return fmp4InitFileName;
         }
 
-        /// <summary>
-        /// Gets the hls playlist text.
-        /// </summary>
-        /// <param name="path">The path to the playlist file.</param>
-        /// <param name="state">The <see cref="StreamState"/>.</param>
-        /// <returns>The playlist text as a string.</returns>
-        public static string GetLivePlaylistText(string path, StreamState state)
+        if (OperatingSystem.IsWindows())
         {
-            var text = File.ReadAllText(path);
+            // on Windows
+            // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4"
+            fmp4InitFileName = outputPrefix + "-1" + outputExtension;
+        }
 
-            var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
-            if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
-            {
-                var fmp4InitFileName = GetFmp4InitFileName(path, state, true);
-                var baseUrlParam = string.Format(
-                    CultureInfo.InvariantCulture,
-                    "hls/{0}/",
-                    Path.GetFileNameWithoutExtension(path));
-                var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false);
+        return fmp4InitFileName;
+    }
 
-                // Replace fMP4 init file URI.
-                text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture);
-            }
+    /// <summary>
+    /// Gets the hls playlist text.
+    /// </summary>
+    /// <param name="path">The path to the playlist file.</param>
+    /// <param name="state">The <see cref="StreamState"/>.</param>
+    /// <returns>The playlist text as a string.</returns>
+    public static string GetLivePlaylistText(string path, StreamState state)
+    {
+        var text = File.ReadAllText(path);
+
+        var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
+        if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
+        {
+            var fmp4InitFileName = GetFmp4InitFileName(path, state, true);
+            var baseUrlParam = string.Format(
+                CultureInfo.InvariantCulture,
+                "hls/{0}/",
+                Path.GetFileNameWithoutExtension(path));
+            var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false);
 
-            return text;
+            // Replace fMP4 init file URI.
+            text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture);
         }
+
+        return text;
     }
 }

+ 386 - 387
Jellyfin.Api/Helpers/MediaInfoHelper.cs

@@ -25,476 +25,475 @@ using MediaBrowser.Model.Session;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Helpers
+namespace Jellyfin.Api.Helpers;
+
+/// <summary>
+/// Media info helper.
+/// </summary>
+public class MediaInfoHelper
 {
+    private readonly IUserManager _userManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IMediaSourceManager _mediaSourceManager;
+    private readonly IMediaEncoder _mediaEncoder;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly ILogger<MediaInfoHelper> _logger;
+    private readonly INetworkManager _networkManager;
+    private readonly IDeviceManager _deviceManager;
+
     /// <summary>
-    /// Media info helper.
+    /// Initializes a new instance of the <see cref="MediaInfoHelper"/> class.
     /// </summary>
-    public class MediaInfoHelper
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param>
+    /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+    /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+    public MediaInfoHelper(
+        IUserManager userManager,
+        ILibraryManager libraryManager,
+        IMediaSourceManager mediaSourceManager,
+        IMediaEncoder mediaEncoder,
+        IServerConfigurationManager serverConfigurationManager,
+        ILogger<MediaInfoHelper> logger,
+        INetworkManager networkManager,
+        IDeviceManager deviceManager)
     {
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IMediaEncoder _mediaEncoder;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly ILogger<MediaInfoHelper> _logger;
-        private readonly INetworkManager _networkManager;
-        private readonly IDeviceManager _deviceManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="MediaInfoHelper"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
-        public MediaInfoHelper(
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IMediaSourceManager mediaSourceManager,
-            IMediaEncoder mediaEncoder,
-            IServerConfigurationManager serverConfigurationManager,
-            ILogger<MediaInfoHelper> logger,
-            INetworkManager networkManager,
-            IDeviceManager deviceManager)
-        {
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _mediaSourceManager = mediaSourceManager;
-            _mediaEncoder = mediaEncoder;
-            _serverConfigurationManager = serverConfigurationManager;
-            _logger = logger;
-            _networkManager = networkManager;
-            _deviceManager = deviceManager;
-        }
+        _userManager = userManager;
+        _libraryManager = libraryManager;
+        _mediaSourceManager = mediaSourceManager;
+        _mediaEncoder = mediaEncoder;
+        _serverConfigurationManager = serverConfigurationManager;
+        _logger = logger;
+        _networkManager = networkManager;
+        _deviceManager = deviceManager;
+    }
 
-        /// <summary>
-        /// Get playback info.
-        /// </summary>
-        /// <param name="id">Item id.</param>
-        /// <param name="userId">User Id.</param>
-        /// <param name="mediaSourceId">Media source id.</param>
-        /// <param name="liveStreamId">Live stream id.</param>
-        /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns>
-        public async Task<PlaybackInfoResponse> GetPlaybackInfo(
-            Guid id,
-            Guid? userId,
-            string? mediaSourceId = null,
-            string? liveStreamId = null)
+    /// <summary>
+    /// Get playback info.
+    /// </summary>
+    /// <param name="id">Item id.</param>
+    /// <param name="userId">User Id.</param>
+    /// <param name="mediaSourceId">Media source id.</param>
+    /// <param name="liveStreamId">Live stream id.</param>
+    /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns>
+    public async Task<PlaybackInfoResponse> GetPlaybackInfo(
+        Guid id,
+        Guid? userId,
+        string? mediaSourceId = null,
+        string? liveStreamId = null)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var item = _libraryManager.GetItemById(id);
+        var result = new PlaybackInfoResponse();
+
+        MediaSourceInfo[] mediaSources;
+        if (string.IsNullOrWhiteSpace(liveStreamId))
         {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var item = _libraryManager.GetItemById(id);
-            var result = new PlaybackInfoResponse();
-
-            MediaSourceInfo[] mediaSources;
-            if (string.IsNullOrWhiteSpace(liveStreamId))
-            {
-                // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
-                var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
-
-                if (string.IsNullOrWhiteSpace(mediaSourceId))
-                {
-                    mediaSources = mediaSourcesList.ToArray();
-                }
-                else
-                {
-                    mediaSources = mediaSourcesList
-                        .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
-                        .ToArray();
-                }
-            }
-            else
-            {
-                var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
+            // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
+            var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
 
-                mediaSources = new[] { mediaSource };
-            }
-
-            if (mediaSources.Length == 0)
+            if (string.IsNullOrWhiteSpace(mediaSourceId))
             {
-                result.MediaSources = Array.Empty<MediaSourceInfo>();
-
-                result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream;
+                mediaSources = mediaSourcesList.ToArray();
             }
             else
             {
-                // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it
-                // Should we move this directly into MediaSourceManager?
-                var mediaSourcesClone = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources));
-                if (mediaSourcesClone is not null)
-                {
-                    result.MediaSources = mediaSourcesClone;
-                }
-
-                result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+                mediaSources = mediaSourcesList
+                    .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
+                    .ToArray();
             }
+        }
+        else
+        {
+            var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
 
-            return result;
+            mediaSources = new[] { mediaSource };
         }
 
-        /// <summary>
-        /// SetDeviceSpecificData.
-        /// </summary>
-        /// <param name="item">Item to set data for.</param>
-        /// <param name="mediaSource">Media source info.</param>
-        /// <param name="profile">Device profile.</param>
-        /// <param name="claimsPrincipal">Current claims principal.</param>
-        /// <param name="maxBitrate">Max bitrate.</param>
-        /// <param name="startTimeTicks">Start time ticks.</param>
-        /// <param name="mediaSourceId">Media source id.</param>
-        /// <param name="audioStreamIndex">Audio stream index.</param>
-        /// <param name="subtitleStreamIndex">Subtitle stream index.</param>
-        /// <param name="maxAudioChannels">Max audio channels.</param>
-        /// <param name="playSessionId">Play session id.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="enableDirectPlay">Enable direct play.</param>
-        /// <param name="enableDirectStream">Enable direct stream.</param>
-        /// <param name="enableTranscoding">Enable transcoding.</param>
-        /// <param name="allowVideoStreamCopy">Allow video stream copy.</param>
-        /// <param name="allowAudioStreamCopy">Allow audio stream copy.</param>
-        /// <param name="ipAddress">Requesting IP address.</param>
-        public void SetDeviceSpecificData(
-            BaseItem item,
-            MediaSourceInfo mediaSource,
-            DeviceProfile profile,
-            ClaimsPrincipal claimsPrincipal,
-            int? maxBitrate,
-            long startTimeTicks,
-            string mediaSourceId,
-            int? audioStreamIndex,
-            int? subtitleStreamIndex,
-            int? maxAudioChannels,
-            string playSessionId,
-            Guid userId,
-            bool enableDirectPlay,
-            bool enableDirectStream,
-            bool enableTranscoding,
-            bool allowVideoStreamCopy,
-            bool allowAudioStreamCopy,
-            IPAddress ipAddress)
+        if (mediaSources.Length == 0)
         {
-            var streamBuilder = new StreamBuilder(_mediaEncoder, _logger);
+            result.MediaSources = Array.Empty<MediaSourceInfo>();
 
-            var options = new MediaOptions
-            {
-                MediaSources = new[] { mediaSource },
-                Context = EncodingContext.Streaming,
-                DeviceId = claimsPrincipal.GetDeviceId(),
-                ItemId = item.Id,
-                Profile = profile,
-                MaxAudioChannels = maxAudioChannels,
-                AllowAudioStreamCopy = allowAudioStreamCopy,
-                AllowVideoStreamCopy = allowVideoStreamCopy
-            };
-
-            if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase))
+            result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream;
+        }
+        else
+        {
+            // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it
+            // Should we move this directly into MediaSourceManager?
+            var mediaSourcesClone = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources));
+            if (mediaSourcesClone is not null)
             {
-                options.MediaSourceId = mediaSourceId;
-                options.AudioStreamIndex = audioStreamIndex;
-                options.SubtitleStreamIndex = subtitleStreamIndex;
+                result.MediaSources = mediaSourcesClone;
             }
 
-            var user = _userManager.GetUserById(userId);
+            result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+        }
 
-            if (!enableDirectPlay)
-            {
-                mediaSource.SupportsDirectPlay = false;
-            }
+        return result;
+    }
 
-            if (!enableDirectStream || !allowVideoStreamCopy)
-            {
-                mediaSource.SupportsDirectStream = false;
-            }
+    /// <summary>
+    /// SetDeviceSpecificData.
+    /// </summary>
+    /// <param name="item">Item to set data for.</param>
+    /// <param name="mediaSource">Media source info.</param>
+    /// <param name="profile">Device profile.</param>
+    /// <param name="claimsPrincipal">Current claims principal.</param>
+    /// <param name="maxBitrate">Max bitrate.</param>
+    /// <param name="startTimeTicks">Start time ticks.</param>
+    /// <param name="mediaSourceId">Media source id.</param>
+    /// <param name="audioStreamIndex">Audio stream index.</param>
+    /// <param name="subtitleStreamIndex">Subtitle stream index.</param>
+    /// <param name="maxAudioChannels">Max audio channels.</param>
+    /// <param name="playSessionId">Play session id.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="enableDirectPlay">Enable direct play.</param>
+    /// <param name="enableDirectStream">Enable direct stream.</param>
+    /// <param name="enableTranscoding">Enable transcoding.</param>
+    /// <param name="allowVideoStreamCopy">Allow video stream copy.</param>
+    /// <param name="allowAudioStreamCopy">Allow audio stream copy.</param>
+    /// <param name="ipAddress">Requesting IP address.</param>
+    public void SetDeviceSpecificData(
+        BaseItem item,
+        MediaSourceInfo mediaSource,
+        DeviceProfile profile,
+        ClaimsPrincipal claimsPrincipal,
+        int? maxBitrate,
+        long startTimeTicks,
+        string mediaSourceId,
+        int? audioStreamIndex,
+        int? subtitleStreamIndex,
+        int? maxAudioChannels,
+        string playSessionId,
+        Guid userId,
+        bool enableDirectPlay,
+        bool enableDirectStream,
+        bool enableTranscoding,
+        bool allowVideoStreamCopy,
+        bool allowAudioStreamCopy,
+        IPAddress ipAddress)
+    {
+        var streamBuilder = new StreamBuilder(_mediaEncoder, _logger);
 
-            if (!enableTranscoding)
-            {
-                mediaSource.SupportsTranscoding = false;
-            }
+        var options = new MediaOptions
+        {
+            MediaSources = new[] { mediaSource },
+            Context = EncodingContext.Streaming,
+            DeviceId = claimsPrincipal.GetDeviceId(),
+            ItemId = item.Id,
+            Profile = profile,
+            MaxAudioChannels = maxAudioChannels,
+            AllowAudioStreamCopy = allowAudioStreamCopy,
+            AllowVideoStreamCopy = allowVideoStreamCopy
+        };
+
+        if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase))
+        {
+            options.MediaSourceId = mediaSourceId;
+            options.AudioStreamIndex = audioStreamIndex;
+            options.SubtitleStreamIndex = subtitleStreamIndex;
+        }
 
-            if (item is Audio)
-            {
-                _logger.LogInformation(
-                    "User policy for {0}. EnableAudioPlaybackTranscoding: {1}",
-                    user.Username,
-                    user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
-            }
-            else
-            {
-                _logger.LogInformation(
-                    "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}",
-                    user.Username,
-                    user.HasPermission(PermissionKind.EnablePlaybackRemuxing),
-                    user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding),
-                    user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
-            }
+        var user = _userManager.GetUserById(userId);
 
-            options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress);
+        if (!enableDirectPlay)
+        {
+            mediaSource.SupportsDirectPlay = false;
+        }
 
-            if (!options.ForceDirectStream)
-            {
-                // direct-stream http streaming is currently broken
-                options.EnableDirectStream = false;
-            }
+        if (!enableDirectStream || !allowVideoStreamCopy)
+        {
+            mediaSource.SupportsDirectStream = false;
+        }
 
-            // Beginning of Playback Determination
-            var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
-                ? streamBuilder.GetOptimalAudioStream(options)
-                : streamBuilder.GetOptimalVideoStream(options);
+        if (!enableTranscoding)
+        {
+            mediaSource.SupportsTranscoding = false;
+        }
 
-            if (streamInfo is not null)
-            {
-                streamInfo.PlaySessionId = playSessionId;
-                streamInfo.StartPositionTicks = startTimeTicks;
+        if (item is Audio)
+        {
+            _logger.LogInformation(
+                "User policy for {0}. EnableAudioPlaybackTranscoding: {1}",
+                user.Username,
+                user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
+        }
+        else
+        {
+            _logger.LogInformation(
+                "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}",
+                user.Username,
+                user.HasPermission(PermissionKind.EnablePlaybackRemuxing),
+                user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding),
+                user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
+        }
 
-                mediaSource.SupportsDirectPlay = streamInfo.PlayMethod == PlayMethod.DirectPlay;
+        options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress);
 
-                // Players do not handle this being set according to PlayMethod
-                mediaSource.SupportsDirectStream =
-                    options.EnableDirectStream
-                        ? streamInfo.PlayMethod == PlayMethod.DirectPlay || streamInfo.PlayMethod == PlayMethod.DirectStream
-                        : streamInfo.PlayMethod == PlayMethod.DirectPlay;
+        if (!options.ForceDirectStream)
+        {
+            // direct-stream http streaming is currently broken
+            options.EnableDirectStream = false;
+        }
 
-                mediaSource.SupportsTranscoding =
-                    streamInfo.PlayMethod == PlayMethod.DirectStream
-                    || mediaSource.TranscodingContainer is not null
-                    || profile.TranscodingProfiles.Any(i => i.Type == streamInfo.MediaType && i.Context == options.Context);
+        // Beginning of Playback Determination
+        var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
+            ? streamBuilder.GetOptimalAudioStream(options)
+            : streamBuilder.GetOptimalVideoStream(options);
 
-                if (item is Audio)
-                {
-                    if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding))
-                    {
-                        mediaSource.SupportsTranscoding = false;
-                    }
-                }
-                else if (item is Video)
-                {
-                    if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)
-                        && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)
-                        && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing))
-                    {
-                        mediaSource.SupportsTranscoding = false;
-                    }
-                }
+        if (streamInfo is not null)
+        {
+            streamInfo.PlaySessionId = playSessionId;
+            streamInfo.StartPositionTicks = startTimeTicks;
 
-                if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
-                {
-                    mediaSource.SupportsDirectPlay = false;
-                    mediaSource.SupportsDirectStream = false;
+            mediaSource.SupportsDirectPlay = streamInfo.PlayMethod == PlayMethod.DirectPlay;
 
-                    mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-');
-                    mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
-                    mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
-                    mediaSource.TranscodingContainer = streamInfo.Container;
-                    mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
-                }
-                else
+            // Players do not handle this being set according to PlayMethod
+            mediaSource.SupportsDirectStream =
+                options.EnableDirectStream
+                    ? streamInfo.PlayMethod == PlayMethod.DirectPlay || streamInfo.PlayMethod == PlayMethod.DirectStream
+                    : streamInfo.PlayMethod == PlayMethod.DirectPlay;
+
+            mediaSource.SupportsTranscoding =
+                streamInfo.PlayMethod == PlayMethod.DirectStream
+                || mediaSource.TranscodingContainer is not null
+                || profile.TranscodingProfiles.Any(i => i.Type == streamInfo.MediaType && i.Context == options.Context);
+
+            if (item is Audio)
+            {
+                if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding))
                 {
-                    if (!mediaSource.SupportsDirectPlay && (mediaSource.SupportsTranscoding || mediaSource.SupportsDirectStream))
-                    {
-                        streamInfo.PlayMethod = PlayMethod.Transcode;
-                        mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-');
-
-                        if (!allowVideoStreamCopy)
-                        {
-                            mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
-                        }
-
-                        if (!allowAudioStreamCopy)
-                        {
-                            mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
-                        }
-                    }
+                    mediaSource.SupportsTranscoding = false;
                 }
-
-                // Do this after the above so that StartPositionTicks is set
-                // The token must not be null
-                SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, claimsPrincipal.GetToken()!);
-                mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex;
             }
-
-            foreach (var attachment in mediaSource.MediaAttachments)
+            else if (item is Video)
             {
-                attachment.DeliveryUrl = string.Format(
-                    CultureInfo.InvariantCulture,
-                    "/Videos/{0}/{1}/Attachments/{2}",
-                    item.Id,
-                    mediaSource.Id,
-                    attachment.Index);
+                if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)
+                    && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)
+                    && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing))
+                {
+                    mediaSource.SupportsTranscoding = false;
+                }
             }
-        }
 
-        /// <summary>
-        /// Sort media source.
-        /// </summary>
-        /// <param name="result">Playback info response.</param>
-        /// <param name="maxBitrate">Max bitrate.</param>
-        public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
-        {
-            var originalList = result.MediaSources.ToList();
+            if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
+            {
+                mediaSource.SupportsDirectPlay = false;
+                mediaSource.SupportsDirectStream = false;
 
-            result.MediaSources = result.MediaSources.OrderBy(i =>
+                mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-');
+                mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
+                mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
+                mediaSource.TranscodingContainer = streamInfo.Container;
+                mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
+            }
+            else
+            {
+                if (!mediaSource.SupportsDirectPlay && (mediaSource.SupportsTranscoding || mediaSource.SupportsDirectStream))
                 {
-                    // Nothing beats direct playing a file
-                    if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
-                    {
-                        return 0;
-                    }
+                    streamInfo.PlayMethod = PlayMethod.Transcode;
+                    mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-');
 
-                    return 1;
-                })
-                .ThenBy(i =>
-                {
-                    // Let's assume direct streaming a file is just as desirable as direct playing a remote url
-                    if (i.SupportsDirectPlay || i.SupportsDirectStream)
+                    if (!allowVideoStreamCopy)
                     {
-                        return 0;
+                        mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
                     }
 
-                    return 1;
-                })
-                .ThenBy(i =>
-                {
-                    return i.Protocol switch
+                    if (!allowAudioStreamCopy)
                     {
-                        MediaProtocol.File => 0,
-                        _ => 1,
-                    };
-                })
-                .ThenBy(i =>
-                {
-                    if (maxBitrate.HasValue && i.Bitrate.HasValue)
-                    {
-                        return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2;
+                        mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
                     }
+                }
+            }
 
-                    return 1;
-                })
-                .ThenBy(originalList.IndexOf)
-                .ToArray();
+            // Do this after the above so that StartPositionTicks is set
+            // The token must not be null
+            SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, claimsPrincipal.GetToken()!);
+            mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex;
         }
 
-        /// <summary>
-        /// Open media source.
-        /// </summary>
-        /// <param name="httpContext">Http Context.</param>
-        /// <param name="request">Live stream request.</param>
-        /// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns>
-        public async Task<LiveStreamResponse> OpenMediaSource(HttpContext httpContext, LiveStreamRequest request)
+        foreach (var attachment in mediaSource.MediaAttachments)
         {
-            var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
+            attachment.DeliveryUrl = string.Format(
+                CultureInfo.InvariantCulture,
+                "/Videos/{0}/{1}/Attachments/{2}",
+                item.Id,
+                mediaSource.Id,
+                attachment.Index);
+        }
+    }
 
-            var profile = request.DeviceProfile;
-            if (profile is null)
+    /// <summary>
+    /// Sort media source.
+    /// </summary>
+    /// <param name="result">Playback info response.</param>
+    /// <param name="maxBitrate">Max bitrate.</param>
+    public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
+    {
+        var originalList = result.MediaSources.ToList();
+
+        result.MediaSources = result.MediaSources.OrderBy(i =>
             {
-                var clientCapabilities = _deviceManager.GetCapabilities(httpContext.User.GetDeviceId());
-                if (clientCapabilities is not null)
+                // Nothing beats direct playing a file
+                if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
                 {
-                    profile = clientCapabilities.DeviceProfile;
+                    return 0;
                 }
-            }
 
-            if (profile is not null)
+                return 1;
+            })
+            .ThenBy(i =>
             {
-                var item = _libraryManager.GetItemById(request.ItemId);
-
-                SetDeviceSpecificData(
-                    item,
-                    result.MediaSource,
-                    profile,
-                    httpContext.User,
-                    request.MaxStreamingBitrate,
-                    request.StartTimeTicks ?? 0,
-                    result.MediaSource.Id,
-                    request.AudioStreamIndex,
-                    request.SubtitleStreamIndex,
-                    request.MaxAudioChannels,
-                    request.PlaySessionId,
-                    request.UserId,
-                    request.EnableDirectPlay,
-                    request.EnableDirectStream,
-                    true,
-                    true,
-                    true,
-                    httpContext.GetNormalizedRemoteIp());
-            }
-            else
+                // Let's assume direct streaming a file is just as desirable as direct playing a remote url
+                if (i.SupportsDirectPlay || i.SupportsDirectStream)
+                {
+                    return 0;
+                }
+
+                return 1;
+            })
+            .ThenBy(i =>
+            {
+                return i.Protocol switch
+                {
+                    MediaProtocol.File => 0,
+                    _ => 1,
+                };
+            })
+            .ThenBy(i =>
             {
-                if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl))
+                if (maxBitrate.HasValue && i.Bitrate.HasValue)
                 {
-                    result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId;
+                    return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2;
                 }
-            }
 
-            // here was a check if (result.MediaSource is not null) but Rider said it will never be null
-            NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video);
+                return 1;
+            })
+            .ThenBy(originalList.IndexOf)
+            .ToArray();
+    }
 
-            return result;
-        }
+    /// <summary>
+    /// Open media source.
+    /// </summary>
+    /// <param name="httpContext">Http Context.</param>
+    /// <param name="request">Live stream request.</param>
+    /// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns>
+    public async Task<LiveStreamResponse> OpenMediaSource(HttpContext httpContext, LiveStreamRequest request)
+    {
+        var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
 
-        /// <summary>
-        /// Normalize media source container.
-        /// </summary>
-        /// <param name="mediaSource">Media source.</param>
-        /// <param name="profile">Device profile.</param>
-        /// <param name="type">Dlna profile type.</param>
-        public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type)
+        var profile = request.DeviceProfile;
+        if (profile is null)
         {
-            mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, profile, type);
+            var clientCapabilities = _deviceManager.GetCapabilities(httpContext.User.GetDeviceId());
+            if (clientCapabilities is not null)
+            {
+                profile = clientCapabilities.DeviceProfile;
+            }
         }
 
-        private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken)
+        if (profile is not null)
+        {
+            var item = _libraryManager.GetItemById(request.ItemId);
+
+            SetDeviceSpecificData(
+                item,
+                result.MediaSource,
+                profile,
+                httpContext.User,
+                request.MaxStreamingBitrate,
+                request.StartTimeTicks ?? 0,
+                result.MediaSource.Id,
+                request.AudioStreamIndex,
+                request.SubtitleStreamIndex,
+                request.MaxAudioChannels,
+                request.PlaySessionId,
+                request.UserId,
+                request.EnableDirectPlay,
+                request.EnableDirectStream,
+                true,
+                true,
+                true,
+                httpContext.GetNormalizedRemoteIp());
+        }
+        else
         {
-            var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken);
-            mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex;
+            if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl))
+            {
+                result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId;
+            }
+        }
+
+        // here was a check if (result.MediaSource is not null) but Rider said it will never be null
+        NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video);
 
-            mediaSource.TranscodeReasons = info.TranscodeReasons;
+        return result;
+    }
 
-            foreach (var profile in profiles)
+    /// <summary>
+    /// Normalize media source container.
+    /// </summary>
+    /// <param name="mediaSource">Media source.</param>
+    /// <param name="profile">Device profile.</param>
+    /// <param name="type">Dlna profile type.</param>
+    public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type)
+    {
+        mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, profile, type);
+    }
+
+    private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken)
+    {
+        var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken);
+        mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex;
+
+        mediaSource.TranscodeReasons = info.TranscodeReasons;
+
+        foreach (var profile in profiles)
+        {
+            foreach (var stream in mediaSource.MediaStreams)
             {
-                foreach (var stream in mediaSource.MediaStreams)
+                if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index)
                 {
-                    if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index)
-                    {
-                        stream.DeliveryMethod = profile.DeliveryMethod;
+                    stream.DeliveryMethod = profile.DeliveryMethod;
 
-                        if (profile.DeliveryMethod == SubtitleDeliveryMethod.External)
-                        {
-                            stream.DeliveryUrl = profile.Url.TrimStart('-');
-                            stream.IsExternalUrl = profile.IsExternalUrl;
-                        }
+                    if (profile.DeliveryMethod == SubtitleDeliveryMethod.External)
+                    {
+                        stream.DeliveryUrl = profile.Url.TrimStart('-');
+                        stream.IsExternalUrl = profile.IsExternalUrl;
                     }
                 }
             }
         }
+    }
+
+    private int? GetMaxBitrate(int? clientMaxBitrate, User user, IPAddress ipAddress)
+    {
+        var maxBitrate = clientMaxBitrate;
+        var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0;
 
-        private int? GetMaxBitrate(int? clientMaxBitrate, User user, IPAddress ipAddress)
+        if (remoteClientMaxBitrate <= 0)
         {
-            var maxBitrate = clientMaxBitrate;
-            var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0;
+            remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit;
+        }
 
-            if (remoteClientMaxBitrate <= 0)
-            {
-                remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit;
-            }
+        if (remoteClientMaxBitrate > 0)
+        {
+            var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress);
 
-            if (remoteClientMaxBitrate > 0)
+            _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork);
+            if (!isInLocalNetwork)
             {
-                var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress);
-
-                _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork);
-                if (!isInLocalNetwork)
-                {
-                    maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate);
-                }
+                maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate);
             }
-
-            return maxBitrate;
         }
+
+        return maxBitrate;
     }
 }

+ 130 - 131
Jellyfin.Api/Helpers/ProgressiveFileStream.cs

@@ -6,178 +6,177 @@ using System.Threading.Tasks;
 using Jellyfin.Api.Models.PlaybackDtos;
 using MediaBrowser.Model.IO;
 
-namespace Jellyfin.Api.Helpers
+namespace Jellyfin.Api.Helpers;
+
+/// <summary>
+/// A progressive file stream for transferring transcoded files as they are written to.
+/// </summary>
+public class ProgressiveFileStream : Stream
 {
+    private readonly Stream _stream;
+    private readonly TranscodingJobDto? _job;
+    private readonly TranscodingJobHelper? _transcodingJobHelper;
+    private readonly int _timeoutMs;
+    private bool _disposed;
+
     /// <summary>
-    /// A progressive file stream for transferring transcoded files as they are written to.
+    /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class.
     /// </summary>
-    public class ProgressiveFileStream : Stream
+    /// <param name="filePath">The path to the transcoded file.</param>
+    /// <param name="job">The transcoding job information.</param>
+    /// <param name="transcodingJobHelper">The transcoding job helper.</param>
+    /// <param name="timeoutMs">The timeout duration in milliseconds.</param>
+    public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, int timeoutMs = 30000)
     {
-        private readonly Stream _stream;
-        private readonly TranscodingJobDto? _job;
-        private readonly TranscodingJobHelper? _transcodingJobHelper;
-        private readonly int _timeoutMs;
-        private bool _disposed;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class.
-        /// </summary>
-        /// <param name="filePath">The path to the transcoded file.</param>
-        /// <param name="job">The transcoding job information.</param>
-        /// <param name="transcodingJobHelper">The transcoding job helper.</param>
-        /// <param name="timeoutMs">The timeout duration in milliseconds.</param>
-        public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, int timeoutMs = 30000)
-        {
-            _job = job;
-            _transcodingJobHelper = transcodingJobHelper;
-            _timeoutMs = timeoutMs;
+        _job = job;
+        _transcodingJobHelper = transcodingJobHelper;
+        _timeoutMs = timeoutMs;
 
-            _stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
-        }
+        _stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class.
-        /// </summary>
-        /// <param name="stream">The stream to progressively copy.</param>
-        /// <param name="timeoutMs">The timeout duration in milliseconds.</param>
-        public ProgressiveFileStream(Stream stream, int timeoutMs = 30000)
-        {
-            _job = null;
-            _transcodingJobHelper = null;
-            _timeoutMs = timeoutMs;
-            _stream = stream;
-        }
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class.
+    /// </summary>
+    /// <param name="stream">The stream to progressively copy.</param>
+    /// <param name="timeoutMs">The timeout duration in milliseconds.</param>
+    public ProgressiveFileStream(Stream stream, int timeoutMs = 30000)
+    {
+        _job = null;
+        _transcodingJobHelper = null;
+        _timeoutMs = timeoutMs;
+        _stream = stream;
+    }
 
-        /// <inheritdoc />
-        public override bool CanRead => _stream.CanRead;
+    /// <inheritdoc />
+    public override bool CanRead => _stream.CanRead;
 
-        /// <inheritdoc />
-        public override bool CanSeek => false;
+    /// <inheritdoc />
+    public override bool CanSeek => false;
 
-        /// <inheritdoc />
-        public override bool CanWrite => false;
+    /// <inheritdoc />
+    public override bool CanWrite => false;
 
-        /// <inheritdoc />
-        public override long Length => throw new NotSupportedException();
+    /// <inheritdoc />
+    public override long Length => throw new NotSupportedException();
 
-        /// <inheritdoc />
-        public override long Position
-        {
-            get => throw new NotSupportedException();
-            set => throw new NotSupportedException();
-        }
+    /// <inheritdoc />
+    public override long Position
+    {
+        get => throw new NotSupportedException();
+        set => throw new NotSupportedException();
+    }
 
-        /// <inheritdoc />
-        public override void Flush()
-        {
-            // Not supported
-        }
+    /// <inheritdoc />
+    public override void Flush()
+    {
+        // Not supported
+    }
 
-        /// <inheritdoc />
-        public override int Read(byte[] buffer, int offset, int count)
-            => Read(buffer.AsSpan(offset, count));
+    /// <inheritdoc />
+    public override int Read(byte[] buffer, int offset, int count)
+        => Read(buffer.AsSpan(offset, count));
 
-        /// <inheritdoc />
-        public override int Read(Span<byte> buffer)
-        {
-            int totalBytesRead = 0;
-            var stopwatch = Stopwatch.StartNew();
+    /// <inheritdoc />
+    public override int Read(Span<byte> buffer)
+    {
+        int totalBytesRead = 0;
+        var stopwatch = Stopwatch.StartNew();
 
-            while (true)
+        while (true)
+        {
+            totalBytesRead += _stream.Read(buffer);
+            if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds))
             {
-                totalBytesRead += _stream.Read(buffer);
-                if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds))
-                {
-                    break;
-                }
-
-                Thread.Sleep(50);
+                break;
             }
 
-            UpdateBytesWritten(totalBytesRead);
-
-            return totalBytesRead;
+            Thread.Sleep(50);
         }
 
-        /// <inheritdoc />
-        public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
-            => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false);
+        UpdateBytesWritten(totalBytesRead);
 
-        /// <inheritdoc />
-        public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
-        {
-            int totalBytesRead = 0;
-            var stopwatch = Stopwatch.StartNew();
+        return totalBytesRead;
+    }
 
-            while (true)
-            {
-                totalBytesRead += await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
-                if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds))
-                {
-                    break;
-                }
+    /// <inheritdoc />
+    public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+        => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false);
 
-                await Task.Delay(50, cancellationToken).ConfigureAwait(false);
-            }
+    /// <inheritdoc />
+    public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
+    {
+        int totalBytesRead = 0;
+        var stopwatch = Stopwatch.StartNew();
 
-            UpdateBytesWritten(totalBytesRead);
+        while (true)
+        {
+            totalBytesRead += await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
+            if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds))
+            {
+                break;
+            }
 
-            return totalBytesRead;
+            await Task.Delay(50, cancellationToken).ConfigureAwait(false);
         }
 
-        /// <inheritdoc />
-        public override long Seek(long offset, SeekOrigin origin)
-            => throw new NotSupportedException();
+        UpdateBytesWritten(totalBytesRead);
+
+        return totalBytesRead;
+    }
+
+    /// <inheritdoc />
+    public override long Seek(long offset, SeekOrigin origin)
+        => throw new NotSupportedException();
 
-        /// <inheritdoc />
-        public override void SetLength(long value)
-            => throw new NotSupportedException();
+    /// <inheritdoc />
+    public override void SetLength(long value)
+        => throw new NotSupportedException();
 
-        /// <inheritdoc />
-        public override void Write(byte[] buffer, int offset, int count)
-            => throw new NotSupportedException();
+    /// <inheritdoc />
+    public override void Write(byte[] buffer, int offset, int count)
+        => throw new NotSupportedException();
 
-        /// <inheritdoc />
-        protected override void Dispose(bool disposing)
+    /// <inheritdoc />
+    protected override void Dispose(bool disposing)
+    {
+        if (_disposed)
         {
-            if (_disposed)
-            {
-                return;
-            }
+            return;
+        }
 
-            try
+        try
+        {
+            if (disposing)
             {
-                if (disposing)
-                {
-                    _stream.Dispose();
+                _stream.Dispose();
 
-                    if (_job is not null)
-                    {
-                        _transcodingJobHelper?.OnTranscodeEndRequest(_job);
-                    }
+                if (_job is not null)
+                {
+                    _transcodingJobHelper?.OnTranscodeEndRequest(_job);
                 }
             }
-            finally
-            {
-                _disposed = true;
-                base.Dispose(disposing);
-            }
         }
-
-        private void UpdateBytesWritten(int totalBytesRead)
+        finally
         {
-            if (_job is not null)
-            {
-                _job.BytesDownloaded += totalBytesRead;
-            }
+            _disposed = true;
+            base.Dispose(disposing);
         }
+    }
 
-        private bool StopReading(int bytesRead, long elapsed)
+    private void UpdateBytesWritten(int totalBytesRead)
+    {
+        if (_job is not null)
         {
-            // It should stop reading when anything has been successfully read or if the job has exited
-            // If the job is null, however, it's a live stream and will require user action to close,
-            // but don't keep it open indefinitely if it isn't reading anything
-            return bytesRead > 0 || (_job?.HasExited ?? elapsed >= _timeoutMs);
+            _job.BytesDownloaded += totalBytesRead;
         }
     }
+
+    private bool StopReading(int bytesRead, long elapsed)
+    {
+        // It should stop reading when anything has been successfully read or if the job has exited
+        // If the job is null, however, it's a live stream and will require user action to close,
+        // but don't keep it open indefinitely if it isn't reading anything
+        return bytesRead > 0 || (_job?.HasExited ?? elapsed >= _timeoutMs);
+    }
 }

+ 105 - 106
Jellyfin.Api/Helpers/RequestHelpers.cs

@@ -16,133 +16,132 @@ using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Http;
 
-namespace Jellyfin.Api.Helpers
+namespace Jellyfin.Api.Helpers;
+
+/// <summary>
+/// Request Extensions.
+/// </summary>
+public static class RequestHelpers
 {
     /// <summary>
-    /// Request Extensions.
+    /// Get Order By.
     /// </summary>
-    public static class RequestHelpers
+    /// <param name="sortBy">Sort By. Comma delimited string.</param>
+    /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param>
+    /// <returns>Order By.</returns>
+    public static (string, SortOrder)[] GetOrderBy(IReadOnlyList<string> sortBy, IReadOnlyList<SortOrder> requestedSortOrder)
     {
-        /// <summary>
-        /// Get Order By.
-        /// </summary>
-        /// <param name="sortBy">Sort By. Comma delimited string.</param>
-        /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param>
-        /// <returns>Order By.</returns>
-        public static (string, SortOrder)[] GetOrderBy(IReadOnlyList<string> sortBy, IReadOnlyList<SortOrder> requestedSortOrder)
+        if (sortBy.Count == 0)
         {
-            if (sortBy.Count == 0)
-            {
-                return Array.Empty<(string, SortOrder)>();
-            }
-
-            var result = new (string, SortOrder)[sortBy.Count];
-            var i = 0;
-            // Add elements which have a SortOrder specified
-            for (; i < requestedSortOrder.Count; i++)
-            {
-                result[i] = (sortBy[i], requestedSortOrder[i]);
-            }
-
-            // Add remaining elements with the first specified SortOrder
-            // or the default one if no SortOrders are specified
-            var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending;
-            for (; i < sortBy.Count; i++)
-            {
-                result[i] = (sortBy[i], order);
-            }
+            return Array.Empty<(string, SortOrder)>();
+        }
 
-            return result;
+        var result = new (string, SortOrder)[sortBy.Count];
+        var i = 0;
+        // Add elements which have a SortOrder specified
+        for (; i < requestedSortOrder.Count; i++)
+        {
+            result[i] = (sortBy[i], requestedSortOrder[i]);
         }
 
-        /// <summary>
-        /// Checks if the user can update an entry.
-        /// </summary>
-        /// <param name="userManager">An instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="claimsPrincipal">The <see cref="ClaimsPrincipal"/> for the current request.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param>
-        /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns>
-        internal static bool AssertCanUpdateUser(IUserManager userManager, ClaimsPrincipal claimsPrincipal, Guid userId, bool restrictUserPreferences)
+        // Add remaining elements with the first specified SortOrder
+        // or the default one if no SortOrders are specified
+        var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending;
+        for (; i < sortBy.Count; i++)
         {
-            var authenticatedUserId = claimsPrincipal.GetUserId();
-            var isAdministrator = claimsPrincipal.IsInRole(UserRoles.Administrator);
+            result[i] = (sortBy[i], order);
+        }
 
-            // If they're going to update the record of another user, they must be an administrator
-            if (!userId.Equals(authenticatedUserId) && !isAdministrator)
-            {
-                return false;
-            }
+        return result;
+    }
 
-            // TODO the EnableUserPreferenceAccess policy does not seem to be used elsewhere
-            if (!restrictUserPreferences || isAdministrator)
-            {
-                return true;
-            }
+    /// <summary>
+    /// Checks if the user can update an entry.
+    /// </summary>
+    /// <param name="userManager">An instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="claimsPrincipal">The <see cref="ClaimsPrincipal"/> for the current request.</param>
+    /// <param name="userId">The user id.</param>
+    /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param>
+    /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns>
+    internal static bool AssertCanUpdateUser(IUserManager userManager, ClaimsPrincipal claimsPrincipal, Guid userId, bool restrictUserPreferences)
+    {
+        var authenticatedUserId = claimsPrincipal.GetUserId();
+        var isAdministrator = claimsPrincipal.IsInRole(UserRoles.Administrator);
 
-            var user = userManager.GetUserById(userId);
-            return user.EnableUserPreferenceAccess;
+        // If they're going to update the record of another user, they must be an administrator
+        if (!userId.Equals(authenticatedUserId) && !isAdministrator)
+        {
+            return false;
         }
 
-        internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext)
+        // TODO the EnableUserPreferenceAccess policy does not seem to be used elsewhere
+        if (!restrictUserPreferences || isAdministrator)
         {
-            var userId = httpContext.User.GetUserId();
-            var user = userManager.GetUserById(userId);
-            var session = await sessionManager.LogSessionActivity(
-                httpContext.User.GetClient(),
-                httpContext.User.GetVersion(),
-                httpContext.User.GetDeviceId(),
-                httpContext.User.GetDevice(),
-                httpContext.GetNormalizedRemoteIp().ToString(),
-                user).ConfigureAwait(false);
-
-            if (session is null)
-            {
-                throw new ArgumentException("Session not found.");
-            }
-
-            return session;
+            return true;
         }
 
-        internal static async Task<string> GetSessionId(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext)
-        {
-            var session = await GetSession(sessionManager, userManager, httpContext).ConfigureAwait(false);
+        var user = userManager.GetUserById(userId);
+        return user.EnableUserPreferenceAccess;
+    }
 
-            return session.Id;
+    internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext)
+    {
+        var userId = httpContext.User.GetUserId();
+        var user = userManager.GetUserById(userId);
+        var session = await sessionManager.LogSessionActivity(
+            httpContext.User.GetClient(),
+            httpContext.User.GetVersion(),
+            httpContext.User.GetDeviceId(),
+            httpContext.User.GetDevice(),
+            httpContext.GetNormalizedRemoteIp().ToString(),
+            user).ConfigureAwait(false);
+
+        if (session is null)
+        {
+            throw new ArgumentException("Session not found.");
         }
 
-        internal static QueryResult<BaseItemDto> CreateQueryResult(
-            QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result,
-            DtoOptions dtoOptions,
-            IDtoService dtoService,
-            bool includeItemTypes,
-            User? user)
+        return session;
+    }
+
+    internal static async Task<string> GetSessionId(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext)
+    {
+        var session = await GetSession(sessionManager, userManager, httpContext).ConfigureAwait(false);
+
+        return session.Id;
+    }
+
+    internal static QueryResult<BaseItemDto> CreateQueryResult(
+        QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result,
+        DtoOptions dtoOptions,
+        IDtoService dtoService,
+        bool includeItemTypes,
+        User? user)
+    {
+        var dtos = result.Items.Select(i =>
         {
-            var dtos = result.Items.Select(i =>
+            var (baseItem, counts) = i;
+            var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+            if (includeItemTypes)
             {
-                var (baseItem, counts) = i;
-                var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
-                if (includeItemTypes)
-                {
-                    dto.ChildCount = counts.ItemCount;
-                    dto.ProgramCount = counts.ProgramCount;
-                    dto.SeriesCount = counts.SeriesCount;
-                    dto.EpisodeCount = counts.EpisodeCount;
-                    dto.MovieCount = counts.MovieCount;
-                    dto.TrailerCount = counts.TrailerCount;
-                    dto.AlbumCount = counts.AlbumCount;
-                    dto.SongCount = counts.SongCount;
-                    dto.ArtistCount = counts.ArtistCount;
-                }
-
-                return dto;
-            });
-
-            return new QueryResult<BaseItemDto>(
-                result.StartIndex,
-                result.TotalRecordCount,
-                dtos.ToArray());
-        }
+                dto.ChildCount = counts.ItemCount;
+                dto.ProgramCount = counts.ProgramCount;
+                dto.SeriesCount = counts.SeriesCount;
+                dto.EpisodeCount = counts.EpisodeCount;
+                dto.MovieCount = counts.MovieCount;
+                dto.TrailerCount = counts.TrailerCount;
+                dto.AlbumCount = counts.AlbumCount;
+                dto.SongCount = counts.SongCount;
+                dto.ArtistCount = counts.ArtistCount;
+            }
+
+            return dto;
+        });
+
+        return new QueryResult<BaseItemDto>(
+            result.StartIndex,
+            result.TotalRecordCount,
+            dtos.ToArray());
     }
 }

+ 616 - 617
Jellyfin.Api/Helpers/StreamingHelpers.cs

@@ -22,761 +22,760 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Primitives;
 using Microsoft.Net.Http.Headers;
 
-namespace Jellyfin.Api.Helpers
+namespace Jellyfin.Api.Helpers;
+
+/// <summary>
+/// The streaming helpers.
+/// </summary>
+public static class StreamingHelpers
 {
     /// <summary>
-    /// The streaming helpers.
+    /// Gets the current streaming state.
     /// </summary>
-    public static class StreamingHelpers
+    /// <param name="streamingRequest">The <see cref="StreamingRequestDto"/>.</param>
+    /// <param name="httpContext">The <see cref="HttpContext"/>.</param>
+    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+    /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
+    /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+    /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+    /// <param name="transcodingJobHelper">Initialized <see cref="TranscodingJobHelper"/>.</param>
+    /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
+    /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
+    /// <returns>A <see cref="Task"/> containing the current <see cref="StreamState"/>.</returns>
+    public static async Task<StreamState> GetStreamingState(
+        StreamingRequestDto streamingRequest,
+        HttpContext httpContext,
+        IMediaSourceManager mediaSourceManager,
+        IUserManager userManager,
+        ILibraryManager libraryManager,
+        IServerConfigurationManager serverConfigurationManager,
+        IMediaEncoder mediaEncoder,
+        EncodingHelper encodingHelper,
+        IDlnaManager dlnaManager,
+        IDeviceManager deviceManager,
+        TranscodingJobHelper transcodingJobHelper,
+        TranscodingJobType transcodingJobType,
+        CancellationToken cancellationToken)
     {
-        /// <summary>
-        /// Gets the current streaming state.
-        /// </summary>
-        /// <param name="streamingRequest">The <see cref="StreamingRequestDto"/>.</param>
-        /// <param name="httpContext">The <see cref="HttpContext"/>.</param>
-        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
-        /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
-        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
-        /// <param name="transcodingJobHelper">Initialized <see cref="TranscodingJobHelper"/>.</param>
-        /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
-        /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
-        /// <returns>A <see cref="Task"/> containing the current <see cref="StreamState"/>.</returns>
-        public static async Task<StreamState> GetStreamingState(
-            StreamingRequestDto streamingRequest,
-            HttpContext httpContext,
-            IMediaSourceManager mediaSourceManager,
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IServerConfigurationManager serverConfigurationManager,
-            IMediaEncoder mediaEncoder,
-            EncodingHelper encodingHelper,
-            IDlnaManager dlnaManager,
-            IDeviceManager deviceManager,
-            TranscodingJobHelper transcodingJobHelper,
-            TranscodingJobType transcodingJobType,
-            CancellationToken cancellationToken)
-        {
-            var httpRequest = httpContext.Request;
-            // Parse the DLNA time seek header
-            if (!streamingRequest.StartTimeTicks.HasValue)
-            {
-                var timeSeek = httpRequest.Headers["TimeSeekRange.dlna.org"];
+        var httpRequest = httpContext.Request;
+        // Parse the DLNA time seek header
+        if (!streamingRequest.StartTimeTicks.HasValue)
+        {
+            var timeSeek = httpRequest.Headers["TimeSeekRange.dlna.org"];
 
-                streamingRequest.StartTimeTicks = ParseTimeSeekHeader(timeSeek.ToString());
-            }
+            streamingRequest.StartTimeTicks = ParseTimeSeekHeader(timeSeek.ToString());
+        }
 
-            if (!string.IsNullOrWhiteSpace(streamingRequest.Params))
-            {
-                ParseParams(streamingRequest);
-            }
+        if (!string.IsNullOrWhiteSpace(streamingRequest.Params))
+        {
+            ParseParams(streamingRequest);
+        }
 
-            streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query);
-            if (httpRequest.Path.Value is null)
-            {
-                throw new ResourceNotFoundException(nameof(httpRequest.Path));
-            }
+        streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query);
+        if (httpRequest.Path.Value is null)
+        {
+            throw new ResourceNotFoundException(nameof(httpRequest.Path));
+        }
 
-            var url = httpRequest.Path.Value.AsSpan().RightPart('.').ToString();
+        var url = httpRequest.Path.Value.AsSpan().RightPart('.').ToString();
 
-            if (string.IsNullOrEmpty(streamingRequest.AudioCodec))
-            {
-                streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url);
-            }
+        if (string.IsNullOrEmpty(streamingRequest.AudioCodec))
+        {
+            streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url);
+        }
 
-            var enableDlnaHeaders = !string.IsNullOrWhiteSpace(streamingRequest.Params) ||
-                                    streamingRequest.StreamOptions.ContainsKey("dlnaheaders") ||
-                                    string.Equals(httpRequest.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase);
+        var enableDlnaHeaders = !string.IsNullOrWhiteSpace(streamingRequest.Params) ||
+                                streamingRequest.StreamOptions.ContainsKey("dlnaheaders") ||
+                                string.Equals(httpRequest.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase);
 
-            var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper)
-            {
-                Request = streamingRequest,
-                RequestedUrl = url,
-                UserAgent = httpRequest.Headers[HeaderNames.UserAgent],
-                EnableDlnaHeaders = enableDlnaHeaders
-            };
-
-            var userId = httpContext.User.GetUserId();
-            if (!userId.Equals(default))
-            {
-                state.User = userManager.GetUserById(userId);
-            }
+        var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper)
+        {
+            Request = streamingRequest,
+            RequestedUrl = url,
+            UserAgent = httpRequest.Headers[HeaderNames.UserAgent],
+            EnableDlnaHeaders = enableDlnaHeaders
+        };
+
+        var userId = httpContext.User.GetUserId();
+        if (!userId.Equals(default))
+        {
+            state.User = userManager.GetUserById(userId);
+        }
 
-            if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.Request.VideoCodec))
-            {
-                state.SupportedVideoCodecs = state.Request.VideoCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
-                state.Request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault();
-            }
+        if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.Request.VideoCodec))
+        {
+            state.SupportedVideoCodecs = state.Request.VideoCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
+            state.Request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault();
+        }
 
-            if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec))
-            {
-                state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
-                state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(mediaEncoder.CanEncodeToAudioCodec)
-                                           ?? state.SupportedAudioCodecs.FirstOrDefault();
-            }
+        if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec))
+        {
+            state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
+            state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(mediaEncoder.CanEncodeToAudioCodec)
+                                       ?? state.SupportedAudioCodecs.FirstOrDefault();
+        }
 
-            if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec))
-            {
-                state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
-                state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(mediaEncoder.CanEncodeToSubtitleCodec)
-                                              ?? state.SupportedSubtitleCodecs.FirstOrDefault();
-            }
+        if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec))
+        {
+            state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
+            state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(mediaEncoder.CanEncodeToSubtitleCodec)
+                                          ?? state.SupportedSubtitleCodecs.FirstOrDefault();
+        }
 
-            var item = libraryManager.GetItemById(streamingRequest.Id);
+        var item = libraryManager.GetItemById(streamingRequest.Id);
 
-            state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase);
+        state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase);
 
-            MediaSourceInfo? mediaSource = null;
-            if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId))
-            {
-                var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId)
-                    ? transcodingJobHelper.GetTranscodingJob(streamingRequest.PlaySessionId)
-                    : null;
+        MediaSourceInfo? mediaSource = null;
+        if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId))
+        {
+            var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId)
+                ? transcodingJobHelper.GetTranscodingJob(streamingRequest.PlaySessionId)
+                : null;
 
-                if (currentJob is not null)
-                {
-                    mediaSource = currentJob.MediaSource;
-                }
+            if (currentJob is not null)
+            {
+                mediaSource = currentJob.MediaSource;
+            }
 
-                if (mediaSource is null)
-                {
-                    var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false);
+            if (mediaSource is null)
+            {
+                var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false);
 
-                    mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId)
-                        ? mediaSources[0]
-                        : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.Ordinal));
+                mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId)
+                    ? mediaSources[0]
+                    : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.Ordinal));
 
-                    if (mediaSource is null && Guid.Parse(streamingRequest.MediaSourceId).Equals(streamingRequest.Id))
-                    {
-                        mediaSource = mediaSources[0];
-                    }
+                if (mediaSource is null && Guid.Parse(streamingRequest.MediaSourceId).Equals(streamingRequest.Id))
+                {
+                    mediaSource = mediaSources[0];
                 }
             }
-            else
-            {
-                var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false);
-                mediaSource = liveStreamInfo.Item1;
-                state.DirectStreamProvider = liveStreamInfo.Item2;
-            }
+        }
+        else
+        {
+            var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false);
+            mediaSource = liveStreamInfo.Item1;
+            state.DirectStreamProvider = liveStreamInfo.Item2;
+        }
 
-            var encodingOptions = serverConfigurationManager.GetEncodingOptions();
+        var encodingOptions = serverConfigurationManager.GetEncodingOptions();
 
-            encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url);
+        encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url);
 
-            string? containerInternal = Path.GetExtension(state.RequestedUrl);
+        string? containerInternal = Path.GetExtension(state.RequestedUrl);
 
-            if (!string.IsNullOrEmpty(streamingRequest.Container))
-            {
-                containerInternal = streamingRequest.Container;
-            }
+        if (!string.IsNullOrEmpty(streamingRequest.Container))
+        {
+            containerInternal = streamingRequest.Container;
+        }
 
-            if (string.IsNullOrEmpty(containerInternal))
-            {
-                containerInternal = streamingRequest.Static ?
-                    StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, null, DlnaProfileType.Audio)
-                    : GetOutputFileExtension(state, mediaSource);
-            }
+        if (string.IsNullOrEmpty(containerInternal))
+        {
+            containerInternal = streamingRequest.Static ?
+                StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, null, DlnaProfileType.Audio)
+                : GetOutputFileExtension(state, mediaSource);
+        }
 
-            state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
+        state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
 
-            state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream);
+        state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream);
 
-            state.OutputAudioCodec = streamingRequest.AudioCodec;
+        state.OutputAudioCodec = streamingRequest.AudioCodec;
 
-            state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec);
+        state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec);
 
-            if (state.VideoRequest is not null)
-            {
-                state.OutputVideoCodec = state.Request.VideoCodec;
-                state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
+        if (state.VideoRequest is not null)
+        {
+            state.OutputVideoCodec = state.Request.VideoCodec;
+            state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
 
-                encodingHelper.TryStreamCopy(state);
+            encodingHelper.TryStreamCopy(state);
 
-                if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue)
+            if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue)
+            {
+                var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue
+                    && !state.VideoRequest.Height.HasValue
+                    && !state.VideoRequest.MaxWidth.HasValue
+                    && !state.VideoRequest.MaxHeight.HasValue;
+
+                if (isVideoResolutionNotRequested
+                    && state.VideoStream is not null
+                    && state.VideoRequest.VideoBitRate.HasValue
+                    && state.VideoStream.BitRate.HasValue
+                    && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value)
                 {
-                    var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue
-                        && !state.VideoRequest.Height.HasValue
-                        && !state.VideoRequest.MaxWidth.HasValue
-                        && !state.VideoRequest.MaxHeight.HasValue;
-
-                    if (isVideoResolutionNotRequested
-                        && state.VideoStream is not null
-                        && state.VideoRequest.VideoBitRate.HasValue
-                        && state.VideoStream.BitRate.HasValue
-                        && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value)
-                    {
-                        // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested,
-                        // and the requested video bitrate is higher than source video bitrate.
-                        if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue)
-                        {
-                            state.VideoRequest.MaxWidth = state.VideoStream?.Width;
-                            state.VideoRequest.MaxHeight = state.VideoStream?.Height;
-                        }
-                    }
-                    else
+                    // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested,
+                    // and the requested video bitrate is higher than source video bitrate.
+                    if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue)
                     {
-                        var resolution = ResolutionNormalizer.Normalize(
-                            state.VideoStream?.BitRate,
-                            state.OutputVideoBitrate.Value,
-                            state.VideoRequest.MaxWidth,
-                            state.VideoRequest.MaxHeight);
-
-                        state.VideoRequest.MaxWidth = resolution.MaxWidth;
-                        state.VideoRequest.MaxHeight = resolution.MaxHeight;
+                        state.VideoRequest.MaxWidth = state.VideoStream?.Width;
+                        state.VideoRequest.MaxHeight = state.VideoStream?.Height;
                     }
                 }
+                else
+                {
+                    var resolution = ResolutionNormalizer.Normalize(
+                        state.VideoStream?.BitRate,
+                        state.OutputVideoBitrate.Value,
+                        state.VideoRequest.MaxWidth,
+                        state.VideoRequest.MaxHeight);
+
+                    state.VideoRequest.MaxWidth = resolution.MaxWidth;
+                    state.VideoRequest.MaxHeight = resolution.MaxHeight;
+                }
             }
+        }
 
-            ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, httpRequest, streamingRequest.DeviceProfileId, streamingRequest.Static);
+        ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, httpRequest, streamingRequest.DeviceProfileId, streamingRequest.Static);
 
-            var ext = string.IsNullOrWhiteSpace(state.OutputContainer)
-                ? GetOutputFileExtension(state, mediaSource)
-                : ("." + state.OutputContainer);
+        var ext = string.IsNullOrWhiteSpace(state.OutputContainer)
+            ? GetOutputFileExtension(state, mediaSource)
+            : ("." + state.OutputContainer);
 
-            state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId);
+        state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId);
 
-            return state;
-        }
+        return state;
+    }
 
-        /// <summary>
-        /// Adds the dlna headers.
-        /// </summary>
-        /// <param name="state">The state.</param>
-        /// <param name="responseHeaders">The response headers.</param>
-        /// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param>
-        /// <param name="startTimeTicks">The start time in ticks.</param>
-        /// <param name="request">The <see cref="HttpRequest"/>.</param>
-        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-        public static void AddDlnaHeaders(
-            StreamState state,
-            IHeaderDictionary responseHeaders,
-            bool isStaticallyStreamed,
-            long? startTimeTicks,
-            HttpRequest request,
-            IDlnaManager dlnaManager)
+    /// <summary>
+    /// Adds the dlna headers.
+    /// </summary>
+    /// <param name="state">The state.</param>
+    /// <param name="responseHeaders">The response headers.</param>
+    /// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param>
+    /// <param name="startTimeTicks">The start time in ticks.</param>
+    /// <param name="request">The <see cref="HttpRequest"/>.</param>
+    /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+    public static void AddDlnaHeaders(
+        StreamState state,
+        IHeaderDictionary responseHeaders,
+        bool isStaticallyStreamed,
+        long? startTimeTicks,
+        HttpRequest request,
+        IDlnaManager dlnaManager)
+    {
+        if (!state.EnableDlnaHeaders)
         {
-            if (!state.EnableDlnaHeaders)
-            {
-                return;
-            }
+            return;
+        }
 
-            var profile = state.DeviceProfile;
+        var profile = state.DeviceProfile;
 
-            StringValues transferMode = request.Headers["transferMode.dlna.org"];
-            responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString());
-            responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*");
+        StringValues transferMode = request.Headers["transferMode.dlna.org"];
+        responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString());
+        responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*");
 
-            if (state.RunTimeTicks.HasValue)
+        if (state.RunTimeTicks.HasValue)
+        {
+            if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase))
             {
-                if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase))
-                {
-                    var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds;
-                    responseHeaders.Add("MediaInfo.sec", string.Format(
-                        CultureInfo.InvariantCulture,
-                        "SEC_Duration={0};",
-                        Convert.ToInt32(ms)));
-                }
+                var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds;
+                responseHeaders.Add("MediaInfo.sec", string.Format(
+                    CultureInfo.InvariantCulture,
+                    "SEC_Duration={0};",
+                    Convert.ToInt32(ms)));
+            }
 
-                if (!isStaticallyStreamed && profile is not null)
-                {
-                    AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks);
-                }
+            if (!isStaticallyStreamed && profile is not null)
+            {
+                AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks);
             }
+        }
 
-            profile ??= dlnaManager.GetDefaultProfile();
+        profile ??= dlnaManager.GetDefaultProfile();
 
-            var audioCodec = state.ActualOutputAudioCodec;
+        var audioCodec = state.ActualOutputAudioCodec;
 
-            if (!state.IsVideoRequest)
-            {
-                responseHeaders.Add("contentFeatures.dlna.org", ContentFeatureBuilder.BuildAudioHeader(
-                    profile,
-                    state.OutputContainer,
-                    audioCodec,
-                    state.OutputAudioBitrate,
-                    state.OutputAudioSampleRate,
-                    state.OutputAudioChannels,
-                    state.OutputAudioBitDepth,
-                    isStaticallyStreamed,
-                    state.RunTimeTicks,
-                    state.TranscodeSeekInfo));
-            }
-            else
-            {
-                var videoCodec = state.ActualOutputVideoCodec;
+        if (!state.IsVideoRequest)
+        {
+            responseHeaders.Add("contentFeatures.dlna.org", ContentFeatureBuilder.BuildAudioHeader(
+                profile,
+                state.OutputContainer,
+                audioCodec,
+                state.OutputAudioBitrate,
+                state.OutputAudioSampleRate,
+                state.OutputAudioChannels,
+                state.OutputAudioBitDepth,
+                isStaticallyStreamed,
+                state.RunTimeTicks,
+                state.TranscodeSeekInfo));
+        }
+        else
+        {
+            var videoCodec = state.ActualOutputVideoCodec;
 
-                responseHeaders.Add(
-                    "contentFeatures.dlna.org",
-                    ContentFeatureBuilder.BuildVideoHeader(profile, state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoRangeType, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty);
-            }
+            responseHeaders.Add(
+                "contentFeatures.dlna.org",
+                ContentFeatureBuilder.BuildVideoHeader(profile, state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoRangeType, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty);
         }
+    }
 
-        /// <summary>
-        /// Parses the time seek header.
-        /// </summary>
-        /// <param name="value">The time seek header string.</param>
-        /// <returns>A nullable <see cref="long"/> representing the seek time in ticks.</returns>
-        private static long? ParseTimeSeekHeader(ReadOnlySpan<char> value)
+    /// <summary>
+    /// Parses the time seek header.
+    /// </summary>
+    /// <param name="value">The time seek header string.</param>
+    /// <returns>A nullable <see cref="long"/> representing the seek time in ticks.</returns>
+    private static long? ParseTimeSeekHeader(ReadOnlySpan<char> value)
+    {
+        if (value.IsEmpty)
         {
-            if (value.IsEmpty)
-            {
-                return null;
-            }
+            return null;
+        }
 
-            const string npt = "npt=";
-            if (!value.StartsWith(npt, StringComparison.OrdinalIgnoreCase))
-            {
-                throw new ArgumentException("Invalid timeseek header");
-            }
+        const string npt = "npt=";
+        if (!value.StartsWith(npt, StringComparison.OrdinalIgnoreCase))
+        {
+            throw new ArgumentException("Invalid timeseek header");
+        }
 
-            var index = value.IndexOf('-');
-            value = index == -1
-                ? value.Slice(npt.Length)
-                : value.Slice(npt.Length, index - npt.Length);
-            if (value.IndexOf(':') == -1)
+        var index = value.IndexOf('-');
+        value = index == -1
+            ? value.Slice(npt.Length)
+            : value.Slice(npt.Length, index - npt.Length);
+        if (value.IndexOf(':') == -1)
+        {
+            // Parses npt times in the format of '417.33'
+            if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds))
             {
-                // Parses npt times in the format of '417.33'
-                if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds))
-                {
-                    return TimeSpan.FromSeconds(seconds).Ticks;
-                }
-
-                throw new ArgumentException("Invalid timeseek header");
+                return TimeSpan.FromSeconds(seconds).Ticks;
             }
 
-            try
-            {
-                // Parses npt times in the format of '10:19:25.7'
-                return TimeSpan.Parse(value, CultureInfo.InvariantCulture).Ticks;
-            }
-            catch
-            {
-                throw new ArgumentException("Invalid timeseek header");
-            }
+            throw new ArgumentException("Invalid timeseek header");
         }
 
-        /// <summary>
-        /// Parses query parameters as StreamOptions.
-        /// </summary>
-        /// <param name="queryString">The query string.</param>
-        /// <returns>A <see cref="Dictionary{String,String}"/> containing the stream options.</returns>
-        private static Dictionary<string, string?> ParseStreamOptions(IQueryCollection queryString)
+        try
+        {
+            // Parses npt times in the format of '10:19:25.7'
+            return TimeSpan.Parse(value, CultureInfo.InvariantCulture).Ticks;
+        }
+        catch
+        {
+            throw new ArgumentException("Invalid timeseek header");
+        }
+    }
+
+    /// <summary>
+    /// Parses query parameters as StreamOptions.
+    /// </summary>
+    /// <param name="queryString">The query string.</param>
+    /// <returns>A <see cref="Dictionary{String,String}"/> containing the stream options.</returns>
+    private static Dictionary<string, string?> ParseStreamOptions(IQueryCollection queryString)
+    {
+        Dictionary<string, string?> streamOptions = new Dictionary<string, string?>();
+        foreach (var param in queryString)
         {
-            Dictionary<string, string?> streamOptions = new Dictionary<string, string?>();
-            foreach (var param in queryString)
+            if (char.IsLower(param.Key[0]))
             {
-                if (char.IsLower(param.Key[0]))
-                {
-                    // This was probably not parsed initially and should be a StreamOptions
-                    // or the generated URL should correctly serialize it
-                    // TODO: This should be incorporated either in the lower framework for parsing requests
-                    streamOptions[param.Key] = param.Value;
-                }
+                // This was probably not parsed initially and should be a StreamOptions
+                // or the generated URL should correctly serialize it
+                // TODO: This should be incorporated either in the lower framework for parsing requests
+                streamOptions[param.Key] = param.Value;
             }
-
-            return streamOptions;
         }
 
-        /// <summary>
-        /// Adds the dlna time seek headers to the response.
-        /// </summary>
-        /// <param name="state">The current <see cref="StreamState"/>.</param>
-        /// <param name="responseHeaders">The <see cref="IHeaderDictionary"/> of the response.</param>
-        /// <param name="startTimeTicks">The start time in ticks.</param>
-        private static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks)
-        {
-            var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture);
-            var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture);
+        return streamOptions;
+    }
 
-            responseHeaders.Add("TimeSeekRange.dlna.org", string.Format(
-                CultureInfo.InvariantCulture,
-                "npt={0}-{1}/{1}",
-                startSeconds,
-                runtimeSeconds));
-            responseHeaders.Add("X-AvailableSeekRange", string.Format(
-                CultureInfo.InvariantCulture,
-                "1 npt={0}-{1}",
-                startSeconds,
-                runtimeSeconds));
+    /// <summary>
+    /// Adds the dlna time seek headers to the response.
+    /// </summary>
+    /// <param name="state">The current <see cref="StreamState"/>.</param>
+    /// <param name="responseHeaders">The <see cref="IHeaderDictionary"/> of the response.</param>
+    /// <param name="startTimeTicks">The start time in ticks.</param>
+    private static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks)
+    {
+        var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture);
+        var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture);
+
+        responseHeaders.Add("TimeSeekRange.dlna.org", string.Format(
+            CultureInfo.InvariantCulture,
+            "npt={0}-{1}/{1}",
+            startSeconds,
+            runtimeSeconds));
+        responseHeaders.Add("X-AvailableSeekRange", string.Format(
+            CultureInfo.InvariantCulture,
+            "1 npt={0}-{1}",
+            startSeconds,
+            runtimeSeconds));
+    }
+
+    /// <summary>
+    /// Gets the output file extension.
+    /// </summary>
+    /// <param name="state">The state.</param>
+    /// <param name="mediaSource">The mediaSource.</param>
+    /// <returns>System.String.</returns>
+    private static string? GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource)
+    {
+        var ext = Path.GetExtension(state.RequestedUrl);
+
+        if (!string.IsNullOrEmpty(ext))
+        {
+            return ext;
         }
 
-        /// <summary>
-        /// Gets the output file extension.
-        /// </summary>
-        /// <param name="state">The state.</param>
-        /// <param name="mediaSource">The mediaSource.</param>
-        /// <returns>System.String.</returns>
-        private static string? GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource)
+        // Try to infer based on the desired video codec
+        if (state.IsVideoRequest)
         {
-            var ext = Path.GetExtension(state.RequestedUrl);
+            var videoCodec = state.Request.VideoCodec;
 
-            if (!string.IsNullOrEmpty(ext))
+            if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) ||
+                string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
             {
-                return ext;
+                return ".ts";
             }
 
-            // Try to infer based on the desired video codec
-            if (state.IsVideoRequest)
+            if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase))
             {
-                var videoCodec = state.Request.VideoCodec;
-
-                if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) ||
-                    string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
-                {
-                    return ".ts";
-                }
-
-                if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase))
-                {
-                    return ".ogv";
-                }
-
-                if (string.Equals(videoCodec, "vp8", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase))
-                {
-                    return ".webm";
-                }
-
-                if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase))
-                {
-                    return ".asf";
-                }
+                return ".ogv";
             }
 
-            // Try to infer based on the desired audio codec
-            if (!state.IsVideoRequest)
+            if (string.Equals(videoCodec, "vp8", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase))
             {
-                var audioCodec = state.Request.AudioCodec;
+                return ".webm";
+            }
 
-                if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase))
-                {
-                    return ".aac";
-                }
+            if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase))
+            {
+                return ".asf";
+            }
+        }
 
-                if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase))
-                {
-                    return ".mp3";
-                }
+        // Try to infer based on the desired audio codec
+        if (!state.IsVideoRequest)
+        {
+            var audioCodec = state.Request.AudioCodec;
 
-                if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase))
-                {
-                    return ".ogg";
-                }
+            if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase))
+            {
+                return ".aac";
+            }
 
-                if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase))
-                {
-                    return ".wma";
-                }
+            if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase))
+            {
+                return ".mp3";
             }
 
-            // Fallback to the container of mediaSource
-            if (!string.IsNullOrEmpty(mediaSource?.Container))
+            if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase))
             {
-                var idx = mediaSource.Container.IndexOf(',', StringComparison.OrdinalIgnoreCase);
-                return '.' + (idx == -1 ? mediaSource.Container : mediaSource.Container[..idx]).Trim();
+                return ".ogg";
             }
 
-            return null;
+            if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase))
+            {
+                return ".wma";
+            }
         }
 
-        /// <summary>
-        /// Gets the output file path for transcoding.
-        /// </summary>
-        /// <param name="state">The current <see cref="StreamState"/>.</param>
-        /// <param name="outputFileExtension">The file extension of the output file.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="deviceId">The device id.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <returns>The complete file path, including the folder, for the transcoding file.</returns>
-        private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId)
+        // Fallback to the container of mediaSource
+        if (!string.IsNullOrEmpty(mediaSource?.Container))
         {
-            var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}";
+            var idx = mediaSource.Container.IndexOf(',', StringComparison.OrdinalIgnoreCase);
+            return '.' + (idx == -1 ? mediaSource.Container : mediaSource.Container[..idx]).Trim();
+        }
 
-            var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture);
-            var ext = outputFileExtension?.ToLowerInvariant();
-            var folder = serverConfigurationManager.GetTranscodePath();
+        return null;
+    }
 
-            return Path.Combine(folder, filename + ext);
-        }
+    /// <summary>
+    /// Gets the output file path for transcoding.
+    /// </summary>
+    /// <param name="state">The current <see cref="StreamState"/>.</param>
+    /// <param name="outputFileExtension">The file extension of the output file.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="deviceId">The device id.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <returns>The complete file path, including the folder, for the transcoding file.</returns>
+    private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId)
+    {
+        var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}";
 
-        private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static)
-        {
-            if (!string.IsNullOrWhiteSpace(deviceProfileId))
-            {
-                state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId);
+        var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+        var ext = outputFileExtension?.ToLowerInvariant();
+        var folder = serverConfigurationManager.GetTranscodePath();
 
-                if (state.DeviceProfile is null)
-                {
-                    var caps = deviceManager.GetCapabilities(deviceProfileId);
-                    state.DeviceProfile = caps is null ? dlnaManager.GetProfile(request.Headers) : caps.DeviceProfile;
-                }
-            }
+        return Path.Combine(folder, filename + ext);
+    }
 
-            var profile = state.DeviceProfile;
+    private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static)
+    {
+        if (!string.IsNullOrWhiteSpace(deviceProfileId))
+        {
+            state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId);
 
-            if (profile is null)
+            if (state.DeviceProfile is null)
             {
-                // Don't use settings from the default profile.
-                // Only use a specific profile if it was requested.
-                return;
+                var caps = deviceManager.GetCapabilities(deviceProfileId);
+                state.DeviceProfile = caps is null ? dlnaManager.GetProfile(request.Headers) : caps.DeviceProfile;
             }
+        }
 
-            var audioCodec = state.ActualOutputAudioCodec;
-            var videoCodec = state.ActualOutputVideoCodec;
+        var profile = state.DeviceProfile;
 
-            var mediaProfile = !state.IsVideoRequest
-                ? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth)
-                : profile.GetVideoMediaProfile(
-                    state.OutputContainer,
-                    audioCodec,
-                    videoCodec,
-                    state.OutputWidth,
-                    state.OutputHeight,
-                    state.TargetVideoBitDepth,
-                    state.OutputVideoBitrate,
-                    state.TargetVideoProfile,
-                    state.TargetVideoRangeType,
-                    state.TargetVideoLevel,
-                    state.TargetFramerate,
-                    state.TargetPacketLength,
-                    state.TargetTimestamp,
-                    state.IsTargetAnamorphic,
-                    state.IsTargetInterlaced,
-                    state.TargetRefFrames,
-                    state.TargetVideoStreamCount,
-                    state.TargetAudioStreamCount,
-                    state.TargetVideoCodecTag,
-                    state.IsTargetAVC);
-
-            if (mediaProfile is not null)
-            {
-                state.MimeType = mediaProfile.MimeType;
-            }
+        if (profile is null)
+        {
+            // Don't use settings from the default profile.
+            // Only use a specific profile if it was requested.
+            return;
+        }
 
-            if (!(@static.HasValue && @static.Value))
+        var audioCodec = state.ActualOutputAudioCodec;
+        var videoCodec = state.ActualOutputVideoCodec;
+
+        var mediaProfile = !state.IsVideoRequest
+            ? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth)
+            : profile.GetVideoMediaProfile(
+                state.OutputContainer,
+                audioCodec,
+                videoCodec,
+                state.OutputWidth,
+                state.OutputHeight,
+                state.TargetVideoBitDepth,
+                state.OutputVideoBitrate,
+                state.TargetVideoProfile,
+                state.TargetVideoRangeType,
+                state.TargetVideoLevel,
+                state.TargetFramerate,
+                state.TargetPacketLength,
+                state.TargetTimestamp,
+                state.IsTargetAnamorphic,
+                state.IsTargetInterlaced,
+                state.TargetRefFrames,
+                state.TargetVideoStreamCount,
+                state.TargetAudioStreamCount,
+                state.TargetVideoCodecTag,
+                state.IsTargetAVC);
+
+        if (mediaProfile is not null)
+        {
+            state.MimeType = mediaProfile.MimeType;
+        }
+
+        if (!(@static.HasValue && @static.Value))
+        {
+            var transcodingProfile = !state.IsVideoRequest ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec);
+
+            if (transcodingProfile is not null)
             {
-                var transcodingProfile = !state.IsVideoRequest ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec);
+                state.EstimateContentLength = transcodingProfile.EstimateContentLength;
+                // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode;
+                state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo;
 
-                if (transcodingProfile is not null)
+                if (state.VideoRequest is not null)
                 {
-                    state.EstimateContentLength = transcodingProfile.EstimateContentLength;
-                    // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode;
-                    state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo;
-
-                    if (state.VideoRequest is not null)
-                    {
-                        state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps;
-                        state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest;
-                    }
+                    state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps;
+                    state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest;
                 }
             }
         }
+    }
 
-        /// <summary>
-        /// Parses the parameters.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        private static void ParseParams(StreamingRequestDto request)
+    /// <summary>
+    /// Parses the parameters.
+    /// </summary>
+    /// <param name="request">The request.</param>
+    private static void ParseParams(StreamingRequestDto request)
+    {
+        if (string.IsNullOrEmpty(request.Params))
         {
-            if (string.IsNullOrEmpty(request.Params))
-            {
-                return;
-            }
+            return;
+        }
 
-            var vals = request.Params.Split(';');
+        var vals = request.Params.Split(';');
 
-            var videoRequest = request as VideoRequestDto;
+        var videoRequest = request as VideoRequestDto;
 
-            for (var i = 0; i < vals.Length; i++)
-            {
-                var val = vals[i];
+        for (var i = 0; i < vals.Length; i++)
+        {
+            var val = vals[i];
 
-                if (string.IsNullOrWhiteSpace(val))
-                {
-                    continue;
-                }
+            if (string.IsNullOrWhiteSpace(val))
+            {
+                continue;
+            }
 
-                switch (i)
-                {
-                    case 0:
-                        request.DeviceProfileId = val;
-                        break;
-                    case 1:
-                        request.DeviceId = val;
-                        break;
-                    case 2:
-                        request.MediaSourceId = val;
-                        break;
-                    case 3:
-                        request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
-                        break;
-                    case 4:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.VideoCodec = val;
-                        }
+            switch (i)
+            {
+                case 0:
+                    request.DeviceProfileId = val;
+                    break;
+                case 1:
+                    request.DeviceId = val;
+                    break;
+                case 2:
+                    request.MediaSourceId = val;
+                    break;
+                case 3:
+                    request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                    break;
+                case 4:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.VideoCodec = val;
+                    }
 
-                        break;
-                    case 5:
-                        request.AudioCodec = val;
-                        break;
-                    case 6:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
-                        }
+                    break;
+                case 5:
+                    request.AudioCodec = val;
+                    break;
+                case 6:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
+                    }
 
-                        break;
-                    case 7:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
-                        }
+                    break;
+                case 7:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
+                    }
 
-                        break;
-                    case 8:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture);
-                        }
+                    break;
+                case 8:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture);
+                    }
 
-                        break;
-                    case 9:
-                        request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture);
-                        break;
-                    case 10:
-                        request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
-                        break;
-                    case 11:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture);
-                        }
+                    break;
+                case 9:
+                    request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture);
+                    break;
+                case 10:
+                    request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
+                    break;
+                case 11:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture);
+                    }
 
-                        break;
-                    case 12:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture);
-                        }
+                    break;
+                case 12:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture);
+                    }
 
-                        break;
-                    case 13:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture);
-                        }
+                    break;
+                case 13:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture);
+                    }
 
-                        break;
-                    case 14:
-                        request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
-                        break;
-                    case 15:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.Level = val;
-                        }
+                    break;
+                case 14:
+                    request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
+                    break;
+                case 15:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.Level = val;
+                    }
 
-                        break;
-                    case 16:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture);
-                        }
+                    break;
+                case 16:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture);
+                    }
 
-                        break;
-                    case 17:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture);
-                        }
+                    break;
+                case 17:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture);
+                    }
 
-                        break;
-                    case 18:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.Profile = val;
-                        }
+                    break;
+                case 18:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.Profile = val;
+                    }
 
-                        break;
-                    case 19:
-                        // cabac no longer used
-                        break;
-                    case 20:
-                        request.PlaySessionId = val;
-                        break;
-                    case 21:
-                        // api_key
-                        break;
-                    case 22:
-                        request.LiveStreamId = val;
-                        break;
-                    case 23:
-                        // Duplicating ItemId because of MediaMonkey
-                        break;
-                    case 24:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
-                        }
+                    break;
+                case 19:
+                    // cabac no longer used
+                    break;
+                case 20:
+                    request.PlaySessionId = val;
+                    break;
+                case 21:
+                    // api_key
+                    break;
+                case 22:
+                    request.LiveStreamId = val;
+                    break;
+                case 23:
+                    // Duplicating ItemId because of MediaMonkey
+                    break;
+                case 24:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                    }
 
-                        break;
-                    case 25:
-                        if (!string.IsNullOrWhiteSpace(val) && videoRequest is not null)
+                    break;
+                case 25:
+                    if (!string.IsNullOrWhiteSpace(val) && videoRequest is not null)
+                    {
+                        if (Enum.TryParse(val, out SubtitleDeliveryMethod method))
                         {
-                            if (Enum.TryParse(val, out SubtitleDeliveryMethod method))
-                            {
-                                videoRequest.SubtitleMethod = method;
-                            }
+                            videoRequest.SubtitleMethod = method;
                         }
+                    }
 
-                        break;
-                    case 26:
-                        request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
-                        break;
-                    case 27:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
-                        }
+                    break;
+                case 26:
+                    request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
+                    break;
+                case 27:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                    }
 
-                        break;
-                    case 28:
-                        request.Tag = val;
-                        break;
-                    case 29:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
-                        }
+                    break;
+                case 28:
+                    request.Tag = val;
+                    break;
+                case 29:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                    }
 
-                        break;
-                    case 30:
-                        request.SubtitleCodec = val;
-                        break;
-                    case 31:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
-                        }
+                    break;
+                case 30:
+                    request.SubtitleCodec = val;
+                    break;
+                case 31:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                    }
 
-                        break;
-                    case 32:
-                        if (videoRequest is not null)
-                        {
-                            videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
-                        }
+                    break;
+                case 32:
+                    if (videoRequest is not null)
+                    {
+                        videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
+                    }
 
-                        break;
-                    case 33:
-                        request.TranscodeReasons = val;
-                        break;
-                }
+                    break;
+                case 33:
+                    request.TranscodeReasons = val;
+                    break;
             }
         }
     }

+ 703 - 704
Jellyfin.Api/Helpers/TranscodingJobHelper.cs

@@ -27,888 +27,887 @@ using MediaBrowser.Model.Session;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Helpers
+namespace Jellyfin.Api.Helpers;
+
+/// <summary>
+/// Transcoding job helpers.
+/// </summary>
+public class TranscodingJobHelper : IDisposable
 {
     /// <summary>
-    /// Transcoding job helpers.
+    /// The active transcoding jobs.
+    /// </summary>
+    private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>();
+
+    /// <summary>
+    /// The transcoding locks.
     /// </summary>
-    public class TranscodingJobHelper : IDisposable
+    private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>();
+
+    private readonly IAttachmentExtractor _attachmentExtractor;
+    private readonly IApplicationPaths _appPaths;
+    private readonly EncodingHelper _encodingHelper;
+    private readonly IFileSystem _fileSystem;
+    private readonly ILogger<TranscodingJobHelper> _logger;
+    private readonly IMediaEncoder _mediaEncoder;
+    private readonly IMediaSourceManager _mediaSourceManager;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly ISessionManager _sessionManager;
+    private readonly ILoggerFactory _loggerFactory;
+    private readonly IUserManager _userManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class.
+    /// </summary>
+    /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param>
+    /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param>
+    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
+    /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
+    /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    public TranscodingJobHelper(
+        IAttachmentExtractor attachmentExtractor,
+        IApplicationPaths appPaths,
+        ILogger<TranscodingJobHelper> logger,
+        IMediaSourceManager mediaSourceManager,
+        IFileSystem fileSystem,
+        IMediaEncoder mediaEncoder,
+        IServerConfigurationManager serverConfigurationManager,
+        ISessionManager sessionManager,
+        EncodingHelper encodingHelper,
+        ILoggerFactory loggerFactory,
+        IUserManager userManager)
     {
-        /// <summary>
-        /// The active transcoding jobs.
-        /// </summary>
-        private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>();
-
-        /// <summary>
-        /// The transcoding locks.
-        /// </summary>
-        private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>();
-
-        private readonly IAttachmentExtractor _attachmentExtractor;
-        private readonly IApplicationPaths _appPaths;
-        private readonly EncodingHelper _encodingHelper;
-        private readonly IFileSystem _fileSystem;
-        private readonly ILogger<TranscodingJobHelper> _logger;
-        private readonly IMediaEncoder _mediaEncoder;
-        private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly ISessionManager _sessionManager;
-        private readonly ILoggerFactory _loggerFactory;
-        private readonly IUserManager _userManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class.
-        /// </summary>
-        /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param>
-        /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param>
-        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
-        /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
-        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        public TranscodingJobHelper(
-            IAttachmentExtractor attachmentExtractor,
-            IApplicationPaths appPaths,
-            ILogger<TranscodingJobHelper> logger,
-            IMediaSourceManager mediaSourceManager,
-            IFileSystem fileSystem,
-            IMediaEncoder mediaEncoder,
-            IServerConfigurationManager serverConfigurationManager,
-            ISessionManager sessionManager,
-            EncodingHelper encodingHelper,
-            ILoggerFactory loggerFactory,
-            IUserManager userManager)
-        {
-            _attachmentExtractor = attachmentExtractor;
-            _appPaths = appPaths;
-            _logger = logger;
-            _mediaSourceManager = mediaSourceManager;
-            _fileSystem = fileSystem;
-            _mediaEncoder = mediaEncoder;
-            _serverConfigurationManager = serverConfigurationManager;
-            _sessionManager = sessionManager;
-            _encodingHelper = encodingHelper;
-            _loggerFactory = loggerFactory;
-            _userManager = userManager;
-
-            DeleteEncodedMediaCache();
-
-            sessionManager.PlaybackProgress += OnPlaybackProgress;
-            sessionManager.PlaybackStart += OnPlaybackProgress;
-        }
-
-        /// <summary>
-        /// Get transcoding job.
-        /// </summary>
-        /// <param name="playSessionId">Playback session id.</param>
-        /// <returns>The transcoding job.</returns>
-        public TranscodingJobDto? GetTranscodingJob(string playSessionId)
-        {
-            lock (_activeTranscodingJobs)
-            {
-                return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase));
-            }
-        }
+        _attachmentExtractor = attachmentExtractor;
+        _appPaths = appPaths;
+        _logger = logger;
+        _mediaSourceManager = mediaSourceManager;
+        _fileSystem = fileSystem;
+        _mediaEncoder = mediaEncoder;
+        _serverConfigurationManager = serverConfigurationManager;
+        _sessionManager = sessionManager;
+        _encodingHelper = encodingHelper;
+        _loggerFactory = loggerFactory;
+        _userManager = userManager;
+
+        DeleteEncodedMediaCache();
+
+        sessionManager.PlaybackProgress += OnPlaybackProgress;
+        sessionManager.PlaybackStart += OnPlaybackProgress;
+    }
 
-        /// <summary>
-        /// Get transcoding job.
-        /// </summary>
-        /// <param name="path">Path to the transcoding file.</param>
-        /// <param name="type">The <see cref="TranscodingJobType"/>.</param>
-        /// <returns>The transcoding job.</returns>
-        public TranscodingJobDto? GetTranscodingJob(string path, TranscodingJobType type)
+    /// <summary>
+    /// Get transcoding job.
+    /// </summary>
+    /// <param name="playSessionId">Playback session id.</param>
+    /// <returns>The transcoding job.</returns>
+    public TranscodingJobDto? GetTranscodingJob(string playSessionId)
+    {
+        lock (_activeTranscodingJobs)
         {
-            lock (_activeTranscodingJobs)
-            {
-                return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
-            }
+            return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase));
         }
+    }
 
-        /// <summary>
-        /// Ping transcoding job.
-        /// </summary>
-        /// <param name="playSessionId">Play session id.</param>
-        /// <param name="isUserPaused">Is user paused.</param>
-        /// <exception cref="ArgumentNullException">Play session id is null.</exception>
-        public void PingTranscodingJob(string playSessionId, bool? isUserPaused)
+    /// <summary>
+    /// Get transcoding job.
+    /// </summary>
+    /// <param name="path">Path to the transcoding file.</param>
+    /// <param name="type">The <see cref="TranscodingJobType"/>.</param>
+    /// <returns>The transcoding job.</returns>
+    public TranscodingJobDto? GetTranscodingJob(string path, TranscodingJobType type)
+    {
+        lock (_activeTranscodingJobs)
         {
-            ArgumentException.ThrowIfNullOrEmpty(playSessionId);
+            return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
+        }
+    }
 
-            _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused);
+    /// <summary>
+    /// Ping transcoding job.
+    /// </summary>
+    /// <param name="playSessionId">Play session id.</param>
+    /// <param name="isUserPaused">Is user paused.</param>
+    /// <exception cref="ArgumentNullException">Play session id is null.</exception>
+    public void PingTranscodingJob(string playSessionId, bool? isUserPaused)
+    {
+        ArgumentException.ThrowIfNullOrEmpty(playSessionId);
 
-            List<TranscodingJobDto> jobs;
+        _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused);
 
-            lock (_activeTranscodingJobs)
+        List<TranscodingJobDto> jobs;
+
+        lock (_activeTranscodingJobs)
+        {
+            // This is really only needed for HLS.
+            // Progressive streams can stop on their own reliably.
+            jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList();
+        }
+
+        foreach (var job in jobs)
+        {
+            if (isUserPaused.HasValue)
             {
-                // This is really only needed for HLS.
-                // Progressive streams can stop on their own reliably.
-                jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList();
+                _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id);
+                job.IsUserPaused = isUserPaused.Value;
             }
 
-            foreach (var job in jobs)
-            {
-                if (isUserPaused.HasValue)
-                {
-                    _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id);
-                    job.IsUserPaused = isUserPaused.Value;
-                }
+            PingTimer(job, true);
+        }
+    }
 
-                PingTimer(job, true);
-            }
+    private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn)
+    {
+        if (job.HasExited)
+        {
+            job.StopKillTimer();
+            return;
         }
 
-        private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn)
+        var timerDuration = 10000;
+
+        if (job.Type != TranscodingJobType.Progressive)
         {
-            if (job.HasExited)
-            {
-                job.StopKillTimer();
-                return;
-            }
+            timerDuration = 60000;
+        }
 
-            var timerDuration = 10000;
+        job.PingTimeout = timerDuration;
+        job.LastPingDate = DateTime.UtcNow;
 
-            if (job.Type != TranscodingJobType.Progressive)
-            {
-                timerDuration = 60000;
-            }
+        // Don't start the timer for playback checkins with progressive streaming
+        if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn)
+        {
+            job.StartKillTimer(OnTranscodeKillTimerStopped);
+        }
+        else
+        {
+            job.ChangeKillTimerIfStarted();
+        }
+    }
 
-            job.PingTimeout = timerDuration;
-            job.LastPingDate = DateTime.UtcNow;
+    /// <summary>
+    /// Called when [transcode kill timer stopped].
+    /// </summary>
+    /// <param name="state">The state.</param>
+    private async void OnTranscodeKillTimerStopped(object? state)
+    {
+        var job = state as TranscodingJobDto ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJobDto)}", nameof(state));
+        if (!job.HasExited && job.Type != TranscodingJobType.Progressive)
+        {
+            var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds;
 
-            // Don't start the timer for playback checkins with progressive streaming
-            if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn)
+            if (timeSinceLastPing < job.PingTimeout)
             {
-                job.StartKillTimer(OnTranscodeKillTimerStopped);
-            }
-            else
-            {
-                job.ChangeKillTimerIfStarted();
+                job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout);
+                return;
             }
         }
 
-        /// <summary>
-        /// Called when [transcode kill timer stopped].
-        /// </summary>
-        /// <param name="state">The state.</param>
-        private async void OnTranscodeKillTimerStopped(object? state)
-        {
-            var job = state as TranscodingJobDto ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJobDto)}", nameof(state));
-            if (!job.HasExited && job.Type != TranscodingJobType.Progressive)
-            {
-                var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds;
+        _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
 
-                if (timeSinceLastPing < job.PingTimeout)
-                {
-                    job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout);
-                    return;
-                }
-            }
+        await KillTranscodingJob(job, true, path => true).ConfigureAwait(false);
+    }
+
+    /// <summary>
+    /// Kills the single transcoding job.
+    /// </summary>
+    /// <param name="deviceId">The device id.</param>
+    /// <param name="playSessionId">The play session identifier.</param>
+    /// <param name="deleteFiles">The delete files.</param>
+    /// <returns>Task.</returns>
+    public Task KillTranscodingJobs(string deviceId, string? playSessionId, Func<string, bool> deleteFiles)
+    {
+        return KillTranscodingJobs(
+            j => string.IsNullOrWhiteSpace(playSessionId)
+                ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase)
+                : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase),
+            deleteFiles);
+    }
 
-            _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
+    /// <summary>
+    /// Kills the transcoding jobs.
+    /// </summary>
+    /// <param name="killJob">The kill job.</param>
+    /// <param name="deleteFiles">The delete files.</param>
+    /// <returns>Task.</returns>
+    private Task KillTranscodingJobs(Func<TranscodingJobDto, bool> killJob, Func<string, bool> deleteFiles)
+    {
+        var jobs = new List<TranscodingJobDto>();
 
-            await KillTranscodingJob(job, true, path => true).ConfigureAwait(false);
+        lock (_activeTranscodingJobs)
+        {
+            // This is really only needed for HLS.
+            // Progressive streams can stop on their own reliably.
+            jobs.AddRange(_activeTranscodingJobs.Where(killJob));
         }
 
-        /// <summary>
-        /// Kills the single transcoding job.
-        /// </summary>
-        /// <param name="deviceId">The device id.</param>
-        /// <param name="playSessionId">The play session identifier.</param>
-        /// <param name="deleteFiles">The delete files.</param>
-        /// <returns>Task.</returns>
-        public Task KillTranscodingJobs(string deviceId, string? playSessionId, Func<string, bool> deleteFiles)
+        if (jobs.Count == 0)
         {
-            return KillTranscodingJobs(
-                j => string.IsNullOrWhiteSpace(playSessionId)
-                    ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase)
-                    : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase),
-                deleteFiles);
+            return Task.CompletedTask;
         }
 
-        /// <summary>
-        /// Kills the transcoding jobs.
-        /// </summary>
-        /// <param name="killJob">The kill job.</param>
-        /// <param name="deleteFiles">The delete files.</param>
-        /// <returns>Task.</returns>
-        private Task KillTranscodingJobs(Func<TranscodingJobDto, bool> killJob, Func<string, bool> deleteFiles)
+        IEnumerable<Task> GetKillJobs()
         {
-            var jobs = new List<TranscodingJobDto>();
-
-            lock (_activeTranscodingJobs)
+            foreach (var job in jobs)
             {
-                // This is really only needed for HLS.
-                // Progressive streams can stop on their own reliably.
-                jobs.AddRange(_activeTranscodingJobs.Where(killJob));
+                yield return KillTranscodingJob(job, false, deleteFiles);
             }
+        }
 
-            if (jobs.Count == 0)
-            {
-                return Task.CompletedTask;
-            }
+        return Task.WhenAll(GetKillJobs());
+    }
 
-            IEnumerable<Task> GetKillJobs()
-            {
-                foreach (var job in jobs)
-                {
-                    yield return KillTranscodingJob(job, false, deleteFiles);
-                }
-            }
+    /// <summary>
+    /// Kills the transcoding job.
+    /// </summary>
+    /// <param name="job">The job.</param>
+    /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param>
+    /// <param name="delete">The delete.</param>
+    private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete)
+    {
+        job.DisposeKillTimer();
 
-            return Task.WhenAll(GetKillJobs());
-        }
+        _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
 
-        /// <summary>
-        /// Kills the transcoding job.
-        /// </summary>
-        /// <param name="job">The job.</param>
-        /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param>
-        /// <param name="delete">The delete.</param>
-        private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete)
+        lock (_activeTranscodingJobs)
         {
-            job.DisposeKillTimer();
+            _activeTranscodingJobs.Remove(job);
 
-            _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
-
-            lock (_activeTranscodingJobs)
+            if (job.CancellationTokenSource?.IsCancellationRequested == false)
             {
-                _activeTranscodingJobs.Remove(job);
-
-                if (job.CancellationTokenSource?.IsCancellationRequested == false)
-                {
-                    job.CancellationTokenSource.Cancel();
-                }
+                job.CancellationTokenSource.Cancel();
             }
+        }
 
-            lock (_transcodingLocks)
-            {
-                _transcodingLocks.Remove(job.Path!);
-            }
+        lock (_transcodingLocks)
+        {
+            _transcodingLocks.Remove(job.Path!);
+        }
 
-            lock (job.ProcessLock!)
-            {
-                #pragma warning disable CA1849 // Can't await in lock block
-                job.TranscodingThrottler?.Stop().GetAwaiter().GetResult();
+        lock (job.ProcessLock!)
+        {
+#pragma warning disable CA1849 // Can't await in lock block
+            job.TranscodingThrottler?.Stop().GetAwaiter().GetResult();
 
-                var process = job.Process;
+            var process = job.Process;
 
-                var hasExited = job.HasExited;
+            var hasExited = job.HasExited;
 
-                if (!hasExited)
+            if (!hasExited)
+            {
+                try
                 {
-                    try
-                    {
-                        _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path);
+                    _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path);
 
-                        process!.StandardInput.WriteLine("q");
+                    process!.StandardInput.WriteLine("q");
 
-                        // Need to wait because killing is asynchronous.
-                        if (!process.WaitForExit(5000))
-                        {
-                            _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path);
-                            process.Kill();
-                        }
-                    }
-                    catch (InvalidOperationException)
+                    // Need to wait because killing is asynchronous.
+                    if (!process.WaitForExit(5000))
                     {
+                        _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path);
+                        process.Kill();
                     }
                 }
-                #pragma warning restore CA1849
+                catch (InvalidOperationException)
+                {
+                }
             }
+#pragma warning restore CA1849
+        }
+
+        if (delete(job.Path!))
+        {
+            await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false);
+        }
 
-            if (delete(job.Path!))
+        if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId))
+        {
+            try
             {
-                await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false);
+                await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false);
             }
-
-            if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId))
+            catch (Exception ex)
             {
-                try
-                {
-                    await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false);
-                }
-                catch (Exception ex)
-                {
-                    _logger.LogError(ex, "Error closing live stream for {Path}", job.Path);
-                }
+                _logger.LogError(ex, "Error closing live stream for {Path}", job.Path);
             }
         }
+    }
 
-        private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs)
+    private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs)
+    {
+        if (retryCount >= 10)
         {
-            if (retryCount >= 10)
-            {
-                return;
-            }
+            return;
+        }
 
-            _logger.LogInformation("Deleting partial stream file(s) {Path}", path);
+        _logger.LogInformation("Deleting partial stream file(s) {Path}", path);
 
-            await Task.Delay(delayMs).ConfigureAwait(false);
+        await Task.Delay(delayMs).ConfigureAwait(false);
 
-            try
-            {
-                if (jobType == TranscodingJobType.Progressive)
-                {
-                    DeleteProgressivePartialStreamFiles(path);
-                }
-                else
-                {
-                    DeleteHlsPartialStreamFiles(path);
-                }
-            }
-            catch (IOException ex)
+        try
+        {
+            if (jobType == TranscodingJobType.Progressive)
             {
-                _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
-
-                await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false);
+                DeleteProgressivePartialStreamFiles(path);
             }
-            catch (Exception ex)
+            else
             {
-                _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
+                DeleteHlsPartialStreamFiles(path);
             }
         }
+        catch (IOException ex)
+        {
+            _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
 
-        /// <summary>
-        /// Deletes the progressive partial stream files.
-        /// </summary>
-        /// <param name="outputFilePath">The output file path.</param>
-        private void DeleteProgressivePartialStreamFiles(string outputFilePath)
+            await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false);
+        }
+        catch (Exception ex)
         {
-            if (File.Exists(outputFilePath))
-            {
-                _fileSystem.DeleteFile(outputFilePath);
-            }
+            _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
         }
+    }
 
-        /// <summary>
-        /// Deletes the HLS partial stream files.
-        /// </summary>
-        /// <param name="outputFilePath">The output file path.</param>
-        private void DeleteHlsPartialStreamFiles(string outputFilePath)
+    /// <summary>
+    /// Deletes the progressive partial stream files.
+    /// </summary>
+    /// <param name="outputFilePath">The output file path.</param>
+    private void DeleteProgressivePartialStreamFiles(string outputFilePath)
+    {
+        if (File.Exists(outputFilePath))
         {
-            var directory = Path.GetDirectoryName(outputFilePath)
-                            ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath));
+            _fileSystem.DeleteFile(outputFilePath);
+        }
+    }
 
-            var name = Path.GetFileNameWithoutExtension(outputFilePath);
+    /// <summary>
+    /// Deletes the HLS partial stream files.
+    /// </summary>
+    /// <param name="outputFilePath">The output file path.</param>
+    private void DeleteHlsPartialStreamFiles(string outputFilePath)
+    {
+        var directory = Path.GetDirectoryName(outputFilePath)
+                        ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath));
+
+        var name = Path.GetFileNameWithoutExtension(outputFilePath);
 
-            var filesToDelete = _fileSystem.GetFilePaths(directory)
-                .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1);
+        var filesToDelete = _fileSystem.GetFilePaths(directory)
+            .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1);
 
-            List<Exception>? exs = null;
-            foreach (var file in filesToDelete)
+        List<Exception>? exs = null;
+        foreach (var file in filesToDelete)
+        {
+            try
             {
-                try
-                {
-                    _logger.LogDebug("Deleting HLS file {0}", file);
-                    _fileSystem.DeleteFile(file);
-                }
-                catch (IOException ex)
-                {
-                    (exs ??= new List<Exception>(4)).Add(ex);
-                    _logger.LogError(ex, "Error deleting HLS file {Path}", file);
-                }
+                _logger.LogDebug("Deleting HLS file {0}", file);
+                _fileSystem.DeleteFile(file);
             }
-
-            if (exs is not null)
+            catch (IOException ex)
             {
-                throw new AggregateException("Error deleting HLS files", exs);
+                (exs ??= new List<Exception>(4)).Add(ex);
+                _logger.LogError(ex, "Error deleting HLS file {Path}", file);
             }
         }
 
-        /// <summary>
-        /// Report the transcoding progress to the session manager.
-        /// </summary>
-        /// <param name="job">The <see cref="TranscodingJobDto"/> of which the progress will be reported.</param>
-        /// <param name="state">The <see cref="StreamState"/> of the current transcoding job.</param>
-        /// <param name="transcodingPosition">The current transcoding position.</param>
-        /// <param name="framerate">The framerate of the transcoding job.</param>
-        /// <param name="percentComplete">The completion percentage of the transcode.</param>
-        /// <param name="bytesTranscoded">The number of bytes transcoded.</param>
-        /// <param name="bitRate">The bitrate of the transcoding job.</param>
-        public void ReportTranscodingProgress(
-            TranscodingJobDto job,
-            StreamState state,
-            TimeSpan? transcodingPosition,
-            float? framerate,
-            double? percentComplete,
-            long? bytesTranscoded,
-            int? bitRate)
-        {
-            var ticks = transcodingPosition?.Ticks;
+        if (exs is not null)
+        {
+            throw new AggregateException("Error deleting HLS files", exs);
+        }
+    }
 
-            if (job is not null)
-            {
-                job.Framerate = framerate;
-                job.CompletionPercentage = percentComplete;
-                job.TranscodingPositionTicks = ticks;
-                job.BytesTranscoded = bytesTranscoded;
-                job.BitRate = bitRate;
-            }
+    /// <summary>
+    /// Report the transcoding progress to the session manager.
+    /// </summary>
+    /// <param name="job">The <see cref="TranscodingJobDto"/> of which the progress will be reported.</param>
+    /// <param name="state">The <see cref="StreamState"/> of the current transcoding job.</param>
+    /// <param name="transcodingPosition">The current transcoding position.</param>
+    /// <param name="framerate">The framerate of the transcoding job.</param>
+    /// <param name="percentComplete">The completion percentage of the transcode.</param>
+    /// <param name="bytesTranscoded">The number of bytes transcoded.</param>
+    /// <param name="bitRate">The bitrate of the transcoding job.</param>
+    public void ReportTranscodingProgress(
+        TranscodingJobDto job,
+        StreamState state,
+        TimeSpan? transcodingPosition,
+        float? framerate,
+        double? percentComplete,
+        long? bytesTranscoded,
+        int? bitRate)
+    {
+        var ticks = transcodingPosition?.Ticks;
 
-            var deviceId = state.Request.DeviceId;
+        if (job is not null)
+        {
+            job.Framerate = framerate;
+            job.CompletionPercentage = percentComplete;
+            job.TranscodingPositionTicks = ticks;
+            job.BytesTranscoded = bytesTranscoded;
+            job.BitRate = bitRate;
+        }
 
-            if (!string.IsNullOrWhiteSpace(deviceId))
-            {
-                var audioCodec = state.ActualOutputAudioCodec;
-                var videoCodec = state.ActualOutputVideoCodec;
-                var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType;
-                HardwareEncodingType? hardwareAccelerationType = null;
-                if (!string.IsNullOrEmpty(hardwareAccelerationTypeString)
-                    && Enum.TryParse<HardwareEncodingType>(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType))
-                {
-                    hardwareAccelerationType = parsedHardwareAccelerationType;
-                }
+        var deviceId = state.Request.DeviceId;
 
-                _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo
-                {
-                    Bitrate = bitRate ?? state.TotalOutputBitrate,
-                    AudioCodec = audioCodec,
-                    VideoCodec = videoCodec,
-                    Container = state.OutputContainer,
-                    Framerate = framerate,
-                    CompletionPercentage = percentComplete,
-                    Width = state.OutputWidth,
-                    Height = state.OutputHeight,
-                    AudioChannels = state.OutputAudioChannels,
-                    IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec),
-                    IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec),
-                    HardwareAccelerationType = hardwareAccelerationType,
-                    TranscodeReasons = state.TranscodeReasons
-                });
-            }
+        if (!string.IsNullOrWhiteSpace(deviceId))
+        {
+            var audioCodec = state.ActualOutputAudioCodec;
+            var videoCodec = state.ActualOutputVideoCodec;
+            var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType;
+            HardwareEncodingType? hardwareAccelerationType = null;
+            if (!string.IsNullOrEmpty(hardwareAccelerationTypeString)
+                && Enum.TryParse<HardwareEncodingType>(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType))
+            {
+                hardwareAccelerationType = parsedHardwareAccelerationType;
+            }
+
+            _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo
+            {
+                Bitrate = bitRate ?? state.TotalOutputBitrate,
+                AudioCodec = audioCodec,
+                VideoCodec = videoCodec,
+                Container = state.OutputContainer,
+                Framerate = framerate,
+                CompletionPercentage = percentComplete,
+                Width = state.OutputWidth,
+                Height = state.OutputHeight,
+                AudioChannels = state.OutputAudioChannels,
+                IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec),
+                IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec),
+                HardwareAccelerationType = hardwareAccelerationType,
+                TranscodeReasons = state.TranscodeReasons
+            });
         }
+    }
 
-        /// <summary>
-        /// Starts FFmpeg.
-        /// </summary>
-        /// <param name="state">The state.</param>
-        /// <param name="outputPath">The output path.</param>
-        /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param>
-        /// <param name="request">The <see cref="HttpRequest"/>.</param>
-        /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
-        /// <param name="cancellationTokenSource">The cancellation token source.</param>
-        /// <param name="workingDirectory">The working directory.</param>
-        /// <returns>Task.</returns>
-        public async Task<TranscodingJobDto> StartFfMpeg(
-            StreamState state,
-            string outputPath,
-            string commandLineArguments,
-            HttpRequest request,
-            TranscodingJobType transcodingJobType,
-            CancellationTokenSource cancellationTokenSource,
-            string? workingDirectory = null)
-        {
-            var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
-            Directory.CreateDirectory(directory);
-
-            await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false);
-
-            if (state.VideoRequest is not null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
-            {
-                var userId = request.HttpContext.User.GetUserId();
-                var user = userId.Equals(default) ? null : _userManager.GetUserById(userId);
-                if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding))
-                {
-                    this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
-
-                    throw new ArgumentException("User does not have access to video transcoding.");
-                }
-            }
+    /// <summary>
+    /// Starts FFmpeg.
+    /// </summary>
+    /// <param name="state">The state.</param>
+    /// <param name="outputPath">The output path.</param>
+    /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param>
+    /// <param name="request">The <see cref="HttpRequest"/>.</param>
+    /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
+    /// <param name="cancellationTokenSource">The cancellation token source.</param>
+    /// <param name="workingDirectory">The working directory.</param>
+    /// <returns>Task.</returns>
+    public async Task<TranscodingJobDto> StartFfMpeg(
+        StreamState state,
+        string outputPath,
+        string commandLineArguments,
+        HttpRequest request,
+        TranscodingJobType transcodingJobType,
+        CancellationTokenSource cancellationTokenSource,
+        string? workingDirectory = null)
+    {
+        var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
+        Directory.CreateDirectory(directory);
 
-            ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath);
+        await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false);
 
-            // If subtitles get burned in fonts may need to be extracted from the media file
-            if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
+        if (state.VideoRequest is not null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+        {
+            var userId = request.HttpContext.User.GetUserId();
+            var user = userId.Equals(default) ? null : _userManager.GetUserById(userId);
+            if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding))
             {
-                var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
-                await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
-
-                if (state.SubtitleStream.IsExternal && string.Equals(Path.GetExtension(state.SubtitleStream.Path), ".mks", StringComparison.OrdinalIgnoreCase))
-                {
-                    string subtitlePath = state.SubtitleStream.Path;
-                    string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal));
-                    string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+                this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
 
-                    await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
-                }
+                throw new ArgumentException("User does not have access to video transcoding.");
             }
+        }
 
-            var process = new Process
-            {
-                StartInfo = new ProcessStartInfo
-                {
-                    WindowStyle = ProcessWindowStyle.Hidden,
-                    CreateNoWindow = true,
-                    UseShellExecute = false,
-
-                    // Must consume both stdout and stderr or deadlocks may occur
-                    // RedirectStandardOutput = true,
-                    RedirectStandardError = true,
-                    RedirectStandardInput = true,
-                    FileName = _mediaEncoder.EncoderPath,
-                    Arguments = commandLineArguments,
-                    WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory,
-                    ErrorDialog = false
-                },
-                EnableRaisingEvents = true
-            };
+        ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath);
+
+        // If subtitles get burned in fonts may need to be extracted from the media file
+        if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
+        {
+            var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
+            await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
 
-            var transcodingJob = this.OnTranscodeBeginning(
-                outputPath,
-                state.Request.PlaySessionId,
-                state.MediaSource.LiveStreamId,
-                Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
-                transcodingJobType,
-                process,
-                state.Request.DeviceId,
-                state,
-                cancellationTokenSource);
-
-            _logger.LogInformation("{Filename} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
-
-            var logFilePrefix = "FFmpeg.Transcode-";
-            if (state.VideoRequest is not null
-                && EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+            if (state.SubtitleStream.IsExternal && string.Equals(Path.GetExtension(state.SubtitleStream.Path), ".mks", StringComparison.OrdinalIgnoreCase))
             {
-                logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec)
-                    ? "FFmpeg.Remux-"
-                    : "FFmpeg.DirectStream-";
+                string subtitlePath = state.SubtitleStream.Path;
+                string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal));
+                string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+
+                await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
             }
+        }
 
-            var logFilePath = Path.Combine(
-                _serverConfigurationManager.ApplicationPaths.LogDirectoryPath,
-                $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log");
+        var process = new Process
+        {
+            StartInfo = new ProcessStartInfo
+            {
+                WindowStyle = ProcessWindowStyle.Hidden,
+                CreateNoWindow = true,
+                UseShellExecute = false,
+
+                // Must consume both stdout and stderr or deadlocks may occur
+                // RedirectStandardOutput = true,
+                RedirectStandardError = true,
+                RedirectStandardInput = true,
+                FileName = _mediaEncoder.EncoderPath,
+                Arguments = commandLineArguments,
+                WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory,
+                ErrorDialog = false
+            },
+            EnableRaisingEvents = true
+        };
+
+        var transcodingJob = this.OnTranscodeBeginning(
+            outputPath,
+            state.Request.PlaySessionId,
+            state.MediaSource.LiveStreamId,
+            Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
+            transcodingJobType,
+            process,
+            state.Request.DeviceId,
+            state,
+            cancellationTokenSource);
+
+        _logger.LogInformation("{Filename} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
+
+        var logFilePrefix = "FFmpeg.Transcode-";
+        if (state.VideoRequest is not null
+            && EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+        {
+            logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec)
+                ? "FFmpeg.Remux-"
+                : "FFmpeg.DirectStream-";
+        }
 
-            // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory.
-            Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
+        var logFilePath = Path.Combine(
+            _serverConfigurationManager.ApplicationPaths.LogDirectoryPath,
+            $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log");
 
-            var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments;
-            var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine);
-            await logStream.WriteAsync(commandLineLogMessageBytes, cancellationTokenSource.Token).ConfigureAwait(false);
+        // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory.
+        Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
 
-            process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state);
+        var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments;
+        var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine);
+        await logStream.WriteAsync(commandLineLogMessageBytes, cancellationTokenSource.Token).ConfigureAwait(false);
 
-            try
-            {
-                process.Start();
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error starting FFmpeg");
+        process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state);
 
-                this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
+        try
+        {
+            process.Start();
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error starting FFmpeg");
 
-                throw;
-            }
+            this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
 
-            _logger.LogDebug("Launched FFmpeg process");
-            state.TranscodingJob = transcodingJob;
+            throw;
+        }
 
-            // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback
-            _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream);
+        _logger.LogDebug("Launched FFmpeg process");
+        state.TranscodingJob = transcodingJob;
 
-            // Wait for the file to exist before proceeding
-            var ffmpegTargetFile = state.WaitForPath ?? outputPath;
-            _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile);
-            while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited)
-            {
-                await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false);
-            }
+        // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback
+        _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream);
 
-            _logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile);
+        // Wait for the file to exist before proceeding
+        var ffmpegTargetFile = state.WaitForPath ?? outputPath;
+        _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile);
+        while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited)
+        {
+            await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false);
+        }
 
-            if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited)
-            {
-                await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false);
+        _logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile);
 
-                if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited)
-                {
-                    await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false);
-                }
-            }
+        if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited)
+        {
+            await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false);
 
-            if (!transcodingJob.HasExited)
-            {
-                StartThrottler(state, transcodingJob);
-            }
-            else if (transcodingJob.ExitCode != 0)
+            if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited)
             {
-                throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "FFmpeg exited with code {0}", transcodingJob.ExitCode));
+                await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false);
             }
+        }
 
-            _logger.LogDebug("StartFfMpeg() finished successfully");
-
-            return transcodingJob;
+        if (!transcodingJob.HasExited)
+        {
+            StartThrottler(state, transcodingJob);
+        }
+        else if (transcodingJob.ExitCode != 0)
+        {
+            throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "FFmpeg exited with code {0}", transcodingJob.ExitCode));
         }
 
-        private void StartThrottler(StreamState state, TranscodingJobDto transcodingJob)
+        _logger.LogDebug("StartFfMpeg() finished successfully");
+
+        return transcodingJob;
+    }
+
+    private void StartThrottler(StreamState state, TranscodingJobDto transcodingJob)
+    {
+        if (EnableThrottling(state))
         {
-            if (EnableThrottling(state))
-            {
-                transcodingJob.TranscodingThrottler = new TranscodingThrottler(transcodingJob, new Logger<TranscodingThrottler>(new LoggerFactory()), _serverConfigurationManager, _fileSystem, _mediaEncoder);
-                transcodingJob.TranscodingThrottler.Start();
-            }
+            transcodingJob.TranscodingThrottler = new TranscodingThrottler(transcodingJob, new Logger<TranscodingThrottler>(new LoggerFactory()), _serverConfigurationManager, _fileSystem, _mediaEncoder);
+            transcodingJob.TranscodingThrottler.Start();
         }
+    }
+
+    private bool EnableThrottling(StreamState state)
+    {
+        var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+
+        return state.InputProtocol == MediaProtocol.File &&
+               state.RunTimeTicks.HasValue &&
+               state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks &&
+               state.IsInputVideo &&
+               state.VideoType == VideoType.VideoFile;
+    }
 
-        private bool EnableThrottling(StreamState state)
+    /// <summary>
+    /// Called when [transcode beginning].
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <param name="playSessionId">The play session identifier.</param>
+    /// <param name="liveStreamId">The live stream identifier.</param>
+    /// <param name="transcodingJobId">The transcoding job identifier.</param>
+    /// <param name="type">The type.</param>
+    /// <param name="process">The process.</param>
+    /// <param name="deviceId">The device id.</param>
+    /// <param name="state">The state.</param>
+    /// <param name="cancellationTokenSource">The cancellation token source.</param>
+    /// <returns>TranscodingJob.</returns>
+    public TranscodingJobDto OnTranscodeBeginning(
+        string path,
+        string? playSessionId,
+        string? liveStreamId,
+        string transcodingJobId,
+        TranscodingJobType type,
+        Process process,
+        string? deviceId,
+        StreamState state,
+        CancellationTokenSource cancellationTokenSource)
+    {
+        lock (_activeTranscodingJobs)
         {
-            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+            var job = new TranscodingJobDto(_loggerFactory.CreateLogger<TranscodingJobDto>())
+            {
+                Type = type,
+                Path = path,
+                Process = process,
+                ActiveRequestCount = 1,
+                DeviceId = deviceId,
+                CancellationTokenSource = cancellationTokenSource,
+                Id = transcodingJobId,
+                PlaySessionId = playSessionId,
+                LiveStreamId = liveStreamId,
+                MediaSource = state.MediaSource
+            };
 
-            return state.InputProtocol == MediaProtocol.File &&
-                   state.RunTimeTicks.HasValue &&
-                   state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks &&
-                   state.IsInputVideo &&
-                   state.VideoType == VideoType.VideoFile;
-        }
-
-        /// <summary>
-        /// Called when [transcode beginning].
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="playSessionId">The play session identifier.</param>
-        /// <param name="liveStreamId">The live stream identifier.</param>
-        /// <param name="transcodingJobId">The transcoding job identifier.</param>
-        /// <param name="type">The type.</param>
-        /// <param name="process">The process.</param>
-        /// <param name="deviceId">The device id.</param>
-        /// <param name="state">The state.</param>
-        /// <param name="cancellationTokenSource">The cancellation token source.</param>
-        /// <returns>TranscodingJob.</returns>
-        public TranscodingJobDto OnTranscodeBeginning(
-            string path,
-            string? playSessionId,
-            string? liveStreamId,
-            string transcodingJobId,
-            TranscodingJobType type,
-            Process process,
-            string? deviceId,
-            StreamState state,
-            CancellationTokenSource cancellationTokenSource)
-        {
-            lock (_activeTranscodingJobs)
-            {
-                var job = new TranscodingJobDto(_loggerFactory.CreateLogger<TranscodingJobDto>())
-                {
-                    Type = type,
-                    Path = path,
-                    Process = process,
-                    ActiveRequestCount = 1,
-                    DeviceId = deviceId,
-                    CancellationTokenSource = cancellationTokenSource,
-                    Id = transcodingJobId,
-                    PlaySessionId = playSessionId,
-                    LiveStreamId = liveStreamId,
-                    MediaSource = state.MediaSource
-                };
-
-                _activeTranscodingJobs.Add(job);
-
-                ReportTranscodingProgress(job, state, null, null, null, null, null);
-
-                return job;
-            }
+            _activeTranscodingJobs.Add(job);
+
+            ReportTranscodingProgress(job, state, null, null, null, null, null);
+
+            return job;
         }
+    }
 
-        /// <summary>
-        /// Called when [transcode end].
-        /// </summary>
-        /// <param name="job">The transcode job.</param>
-        public void OnTranscodeEndRequest(TranscodingJobDto job)
+    /// <summary>
+    /// Called when [transcode end].
+    /// </summary>
+    /// <param name="job">The transcode job.</param>
+    public void OnTranscodeEndRequest(TranscodingJobDto job)
+    {
+        job.ActiveRequestCount--;
+        _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount);
+        if (job.ActiveRequestCount <= 0)
         {
-            job.ActiveRequestCount--;
-            _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount);
-            if (job.ActiveRequestCount <= 0)
-            {
-                PingTimer(job, false);
-            }
+            PingTimer(job, false);
         }
+    }
 
-        /// <summary>
-        /// <summary>
-        /// The progressive
-        /// </summary>
-        /// Called when [transcode failed to start].
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="type">The type.</param>
-        /// <param name="state">The state.</param>
-        public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state)
+    /// <summary>
+    /// <summary>
+    /// The progressive
+    /// </summary>
+    /// Called when [transcode failed to start].
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <param name="type">The type.</param>
+    /// <param name="state">The state.</param>
+    public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state)
+    {
+        lock (_activeTranscodingJobs)
         {
-            lock (_activeTranscodingJobs)
-            {
-                var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
+            var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
 
-                if (job is not null)
-                {
-                    _activeTranscodingJobs.Remove(job);
-                }
-            }
-
-            lock (_transcodingLocks)
+            if (job is not null)
             {
-                _transcodingLocks.Remove(path);
+                _activeTranscodingJobs.Remove(job);
             }
+        }
 
-            if (!string.IsNullOrWhiteSpace(state.Request.DeviceId))
-            {
-                _sessionManager.ClearTranscodingInfo(state.Request.DeviceId);
-            }
+        lock (_transcodingLocks)
+        {
+            _transcodingLocks.Remove(path);
         }
 
-        /// <summary>
-        /// Processes the exited.
-        /// </summary>
-        /// <param name="process">The process.</param>
-        /// <param name="job">The job.</param>
-        /// <param name="state">The state.</param>
-        private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state)
+        if (!string.IsNullOrWhiteSpace(state.Request.DeviceId))
         {
-            job.HasExited = true;
-            job.ExitCode = process.ExitCode;
+            _sessionManager.ClearTranscodingInfo(state.Request.DeviceId);
+        }
+    }
 
-            ReportTranscodingProgress(job, state, null, null, null, null, null);
+    /// <summary>
+    /// Processes the exited.
+    /// </summary>
+    /// <param name="process">The process.</param>
+    /// <param name="job">The job.</param>
+    /// <param name="state">The state.</param>
+    private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state)
+    {
+        job.HasExited = true;
+        job.ExitCode = process.ExitCode;
 
-            _logger.LogDebug("Disposing stream resources");
-            state.Dispose();
+        ReportTranscodingProgress(job, state, null, null, null, null, null);
 
-            if (process.ExitCode == 0)
-            {
-                _logger.LogInformation("FFmpeg exited with code 0");
-            }
-            else
-            {
-                _logger.LogError("FFmpeg exited with code {0}", process.ExitCode);
-            }
+        _logger.LogDebug("Disposing stream resources");
+        state.Dispose();
 
-            job.Dispose();
+        if (process.ExitCode == 0)
+        {
+            _logger.LogInformation("FFmpeg exited with code 0");
         }
-
-        private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource)
+        else
         {
-            if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId))
-            {
-                var liveStreamResponse = await _mediaSourceManager.OpenLiveStream(
-                        new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken },
-                        cancellationTokenSource.Token)
-                    .ConfigureAwait(false);
-                var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+            _logger.LogError("FFmpeg exited with code {0}", process.ExitCode);
+        }
 
-                _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl);
+        job.Dispose();
+    }
 
-                if (state.VideoRequest is not null)
-                {
-                    _encodingHelper.TryStreamCopy(state);
-                }
-            }
+    private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource)
+    {
+        if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId))
+        {
+            var liveStreamResponse = await _mediaSourceManager.OpenLiveStream(
+                    new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken },
+                    cancellationTokenSource.Token)
+                .ConfigureAwait(false);
+            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+
+            _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl);
 
-            if (state.MediaSource.BufferMs.HasValue)
+            if (state.VideoRequest is not null)
             {
-                await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false);
+                _encodingHelper.TryStreamCopy(state);
             }
         }
 
-        /// <summary>
-        /// Called when [transcode begin request].
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="type">The type.</param>
-        /// <returns>The <see cref="TranscodingJobDto"/>.</returns>
-        public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type)
+        if (state.MediaSource.BufferMs.HasValue)
         {
-            lock (_activeTranscodingJobs)
-            {
-                var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
-
-                if (job is null)
-                {
-                    return null;
-                }
-
-                OnTranscodeBeginRequest(job);
-
-                return job;
-            }
+            await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false);
         }
+    }
 
-        private void OnTranscodeBeginRequest(TranscodingJobDto job)
+    /// <summary>
+    /// Called when [transcode begin request].
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <param name="type">The type.</param>
+    /// <returns>The <see cref="TranscodingJobDto"/>.</returns>
+    public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type)
+    {
+        lock (_activeTranscodingJobs)
         {
-            job.ActiveRequestCount++;
+            var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
 
-            if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive)
+            if (job is null)
             {
-                job.StopKillTimer();
+                return null;
             }
+
+            OnTranscodeBeginRequest(job);
+
+            return job;
         }
+    }
 
-        /// <summary>
-        /// Gets the transcoding lock.
-        /// </summary>
-        /// <param name="outputPath">The output path of the transcoded file.</param>
-        /// <returns>A <see cref="SemaphoreSlim"/>.</returns>
-        public SemaphoreSlim GetTranscodingLock(string outputPath)
-        {
-            lock (_transcodingLocks)
-            {
-                if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result))
-                {
-                    result = new SemaphoreSlim(1, 1);
-                    _transcodingLocks[outputPath] = result;
-                }
+    private void OnTranscodeBeginRequest(TranscodingJobDto job)
+    {
+        job.ActiveRequestCount++;
 
-                return result;
-            }
+        if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive)
+        {
+            job.StopKillTimer();
         }
+    }
 
-        private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e)
+    /// <summary>
+    /// Gets the transcoding lock.
+    /// </summary>
+    /// <param name="outputPath">The output path of the transcoded file.</param>
+    /// <returns>A <see cref="SemaphoreSlim"/>.</returns>
+    public SemaphoreSlim GetTranscodingLock(string outputPath)
+    {
+        lock (_transcodingLocks)
         {
-            if (!string.IsNullOrWhiteSpace(e.PlaySessionId))
+            if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result))
             {
-                PingTranscodingJob(e.PlaySessionId, e.IsPaused);
+                result = new SemaphoreSlim(1, 1);
+                _transcodingLocks[outputPath] = result;
             }
+
+            return result;
         }
+    }
 
-        /// <summary>
-        /// Deletes the encoded media cache.
-        /// </summary>
-        private void DeleteEncodedMediaCache()
+    private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e)
+    {
+        if (!string.IsNullOrWhiteSpace(e.PlaySessionId))
         {
-            var path = _serverConfigurationManager.GetTranscodePath();
-            if (!Directory.Exists(path))
-            {
-                return;
-            }
+            PingTranscodingJob(e.PlaySessionId, e.IsPaused);
+        }
+    }
 
-            foreach (var file in _fileSystem.GetFilePaths(path, true))
-            {
-                _fileSystem.DeleteFile(file);
-            }
+    /// <summary>
+    /// Deletes the encoded media cache.
+    /// </summary>
+    private void DeleteEncodedMediaCache()
+    {
+        var path = _serverConfigurationManager.GetTranscodePath();
+        if (!Directory.Exists(path))
+        {
+            return;
         }
 
-        /// <summary>
-        /// Dispose transcoding job helper.
-        /// </summary>
-        public void Dispose()
+        foreach (var file in _fileSystem.GetFilePaths(path, true))
         {
-            Dispose(true);
-            GC.SuppressFinalize(this);
+            _fileSystem.DeleteFile(file);
         }
+    }
 
-        /// <summary>
-        /// Dispose throttler.
-        /// </summary>
-        /// <param name="disposing">Disposing.</param>
-        protected virtual void Dispose(bool disposing)
+    /// <summary>
+    /// Dispose transcoding job helper.
+    /// </summary>
+    public void Dispose()
+    {
+        Dispose(true);
+        GC.SuppressFinalize(this);
+    }
+
+    /// <summary>
+    /// Dispose throttler.
+    /// </summary>
+    /// <param name="disposing">Disposing.</param>
+    protected virtual void Dispose(bool disposing)
+    {
+        if (disposing)
         {
-            if (disposing)
-            {
-                _loggerFactory.Dispose();
-                _sessionManager.PlaybackProgress -= OnPlaybackProgress;
-                _sessionManager.PlaybackStart -= OnPlaybackProgress;
-            }
+            _loggerFactory.Dispose();
+            _sessionManager.PlaybackProgress -= OnPlaybackProgress;
+            _sessionManager.PlaybackStart -= OnPlaybackProgress;
         }
     }
 }

+ 57 - 58
Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs

@@ -7,75 +7,74 @@ using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Logging;
 using static MediaBrowser.Controller.Extensions.ConfigurationExtensions;
 
-namespace Jellyfin.Api.Middleware
+namespace Jellyfin.Api.Middleware;
+
+/// <summary>
+/// Redirect requests without baseurl prefix to the baseurl prefixed URL.
+/// </summary>
+public class BaseUrlRedirectionMiddleware
 {
+    private readonly RequestDelegate _next;
+    private readonly ILogger<BaseUrlRedirectionMiddleware> _logger;
+    private readonly IConfiguration _configuration;
+
     /// <summary>
-    /// Redirect requests without baseurl prefix to the baseurl prefixed URL.
+    /// Initializes a new instance of the <see cref="BaseUrlRedirectionMiddleware"/> class.
     /// </summary>
-    public class BaseUrlRedirectionMiddleware
+    /// <param name="next">The next delegate in the pipeline.</param>
+    /// <param name="logger">The logger.</param>
+    /// <param name="configuration">The application configuration.</param>
+    public BaseUrlRedirectionMiddleware(
+        RequestDelegate next,
+        ILogger<BaseUrlRedirectionMiddleware> logger,
+        IConfiguration configuration)
     {
-        private readonly RequestDelegate _next;
-        private readonly ILogger<BaseUrlRedirectionMiddleware> _logger;
-        private readonly IConfiguration _configuration;
+        _next = next;
+        _logger = logger;
+        _configuration = configuration;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="BaseUrlRedirectionMiddleware"/> class.
-        /// </summary>
-        /// <param name="next">The next delegate in the pipeline.</param>
-        /// <param name="logger">The logger.</param>
-        /// <param name="configuration">The application configuration.</param>
-        public BaseUrlRedirectionMiddleware(
-            RequestDelegate next,
-            ILogger<BaseUrlRedirectionMiddleware> logger,
-            IConfiguration configuration)
-        {
-            _next = next;
-            _logger = logger;
-            _configuration = configuration;
-        }
+    /// <summary>
+    /// Executes the middleware action.
+    /// </summary>
+    /// <param name="httpContext">The current HTTP context.</param>
+    /// <param name="serverConfigurationManager">The server configuration manager.</param>
+    /// <returns>The async task.</returns>
+    public async Task Invoke(HttpContext httpContext, IServerConfigurationManager serverConfigurationManager)
+    {
+        var localPath = httpContext.Request.Path.ToString();
+        var baseUrlPrefix = serverConfigurationManager.GetNetworkConfiguration().BaseUrl;
 
-        /// <summary>
-        /// Executes the middleware action.
-        /// </summary>
-        /// <param name="httpContext">The current HTTP context.</param>
-        /// <param name="serverConfigurationManager">The server configuration manager.</param>
-        /// <returns>The async task.</returns>
-        public async Task Invoke(HttpContext httpContext, IServerConfigurationManager serverConfigurationManager)
+        if (string.IsNullOrEmpty(localPath)
+            || string.Equals(localPath, baseUrlPrefix, StringComparison.OrdinalIgnoreCase)
+            || string.Equals(localPath, baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(localPath, baseUrlPrefix + "/web", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(localPath, baseUrlPrefix + "/web/", StringComparison.OrdinalIgnoreCase)
+            || !localPath.StartsWith(baseUrlPrefix, StringComparison.OrdinalIgnoreCase)
+           )
         {
-            var localPath = httpContext.Request.Path.ToString();
-            var baseUrlPrefix = serverConfigurationManager.GetNetworkConfiguration().BaseUrl;
-
-            if (string.IsNullOrEmpty(localPath)
-                || string.Equals(localPath, baseUrlPrefix, StringComparison.OrdinalIgnoreCase)
-                || string.Equals(localPath, baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(localPath, baseUrlPrefix + "/web", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(localPath, baseUrlPrefix + "/web/", StringComparison.OrdinalIgnoreCase)
-                || !localPath.StartsWith(baseUrlPrefix, StringComparison.OrdinalIgnoreCase)
-               )
+            // Redirect health endpoint
+            if (string.Equals(localPath, "/health", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(localPath, "/health/", StringComparison.OrdinalIgnoreCase))
             {
-                // Redirect health endpoint
-                if (string.Equals(localPath, "/health", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(localPath, "/health/", StringComparison.OrdinalIgnoreCase))
-                {
-                    _logger.LogDebug("Redirecting /health check");
-                    httpContext.Response.Redirect(baseUrlPrefix + "/health");
-                    return;
-                }
-
-                // Always redirect back to the default path if the base prefix is invalid or missing
-                _logger.LogDebug("Normalizing an URL at {LocalPath}", localPath);
-
-                var port = httpContext.Request.Host.Port ?? -1;
-                var uri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, localPath).Uri;
-                var redirectUri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, baseUrlPrefix + "/" + _configuration[DefaultRedirectKey]).Uri;
-                var target = uri.MakeRelativeUri(redirectUri).ToString();
-                _logger.LogDebug("Redirecting to {Target}", target);
-
-                httpContext.Response.Redirect(target);
+                _logger.LogDebug("Redirecting /health check");
+                httpContext.Response.Redirect(baseUrlPrefix + "/health");
                 return;
             }
 
-            await _next(httpContext).ConfigureAwait(false);
+            // Always redirect back to the default path if the base prefix is invalid or missing
+            _logger.LogDebug("Normalizing an URL at {LocalPath}", localPath);
+
+            var port = httpContext.Request.Host.Port ?? -1;
+            var uri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, localPath).Uri;
+            var redirectUri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, baseUrlPrefix + "/" + _configuration[DefaultRedirectKey]).Uri;
+            var target = uri.MakeRelativeUri(redirectUri).ToString();
+            _logger.LogDebug("Redirecting to {Target}", target);
+
+            httpContext.Response.Redirect(target);
+            return;
         }
+
+        await _next(httpContext).ConfigureAwait(false);
     }
 }

+ 107 - 108
Jellyfin.Api/Middleware/ExceptionMiddleware.cs

@@ -12,140 +12,139 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Middleware
+namespace Jellyfin.Api.Middleware;
+
+/// <summary>
+/// Exception Middleware.
+/// </summary>
+public class ExceptionMiddleware
 {
+    private readonly RequestDelegate _next;
+    private readonly ILogger<ExceptionMiddleware> _logger;
+    private readonly IServerConfigurationManager _configuration;
+    private readonly IWebHostEnvironment _hostEnvironment;
+
     /// <summary>
-    /// Exception Middleware.
+    /// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class.
     /// </summary>
-    public class ExceptionMiddleware
+    /// <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,
+        IWebHostEnvironment hostEnvironment)
     {
-        private readonly RequestDelegate _next;
-        private readonly ILogger<ExceptionMiddleware> _logger;
-        private readonly IServerConfigurationManager _configuration;
-        private readonly IWebHostEnvironment _hostEnvironment;
+        _next = next;
+        _logger = logger;
+        _configuration = serverConfigurationManager;
+        _hostEnvironment = hostEnvironment;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class.
-        /// </summary>
-        /// <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,
-            IWebHostEnvironment hostEnvironment)
+    /// <summary>
+    /// Invoke request.
+    /// </summary>
+    /// <param name="context">Request context.</param>
+    /// <returns>Task.</returns>
+    public async Task Invoke(HttpContext context)
+    {
+        try
         {
-            _next = next;
-            _logger = logger;
-            _configuration = serverConfigurationManager;
-            _hostEnvironment = hostEnvironment;
+            await _next(context).ConfigureAwait(false);
         }
-
-        /// <summary>
-        /// Invoke request.
-        /// </summary>
-        /// <param name="context">Request context.</param>
-        /// <returns>Task.</returns>
-        public async Task Invoke(HttpContext context)
+        catch (Exception ex)
         {
-            try
+            if (context.Response.HasStarted)
             {
-                await _next(context).ConfigureAwait(false);
+                _logger.LogWarning("The response has already started, the exception middleware will not be executed.");
+                throw;
             }
-            catch (Exception ex)
-            {
-                if (context.Response.HasStarted)
-                {
-                    _logger.LogWarning("The response has already started, the exception middleware will not be executed.");
-                    throw;
-                }
 
-                ex = GetActualException(ex);
+            ex = GetActualException(ex);
 
-                bool ignoreStackTrace =
-                    ex is SocketException
-                    || ex is IOException
-                    || ex is OperationCanceledException
-                    || ex is SecurityException
-                    || ex is AuthenticationException
-                    || ex is FileNotFoundException;
+            bool ignoreStackTrace =
+                ex is SocketException
+                || ex is IOException
+                || ex is OperationCanceledException
+                || ex is SecurityException
+                || ex is AuthenticationException
+                || ex is FileNotFoundException;
 
-                if (ignoreStackTrace)
-                {
-                    _logger.LogError(
-                        "Error processing request: {ExceptionMessage}. URL {Method} {Url}.",
-                        ex.Message.TrimEnd('.'),
-                        context.Request.Method,
-                        context.Request.Path);
-                }
-                else
-                {
-                    _logger.LogError(
-                        ex,
-                        "Error processing request. URL {Method} {Url}.",
-                        context.Request.Method,
-                        context.Request.Path);
-                }
+            if (ignoreStackTrace)
+            {
+                _logger.LogError(
+                    "Error processing request: {ExceptionMessage}. URL {Method} {Url}.",
+                    ex.Message.TrimEnd('.'),
+                    context.Request.Method,
+                    context.Request.Path);
+            }
+            else
+            {
+                _logger.LogError(
+                    ex,
+                    "Error processing request. URL {Method} {Url}.",
+                    context.Request.Method,
+                    context.Request.Path);
+            }
 
-                context.Response.StatusCode = GetStatusCode(ex);
-                context.Response.ContentType = MediaTypeNames.Text.Plain;
+            context.Response.StatusCode = GetStatusCode(ex);
+            context.Response.ContentType = MediaTypeNames.Text.Plain;
 
-                // 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);
-            }
+            // 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);
         }
+    }
 
-        private static Exception GetActualException(Exception ex)
+    private static Exception GetActualException(Exception ex)
+    {
+        if (ex is AggregateException agg)
         {
-            if (ex is AggregateException agg)
+            var inner = agg.InnerException;
+            if (inner is not null)
             {
-                var inner = agg.InnerException;
-                if (inner is not null)
-                {
-                    return GetActualException(inner);
-                }
-
-                var inners = agg.InnerExceptions;
-                if (inners.Count > 0)
-                {
-                    return GetActualException(inners[0]);
-                }
+                return GetActualException(inner);
             }
 
-            return ex;
-        }
-
-        private static int GetStatusCode(Exception ex)
-        {
-            switch (ex)
+            var inners = agg.InnerExceptions;
+            if (inners.Count > 0)
             {
-                case ArgumentException _: return StatusCodes.Status400BadRequest;
-                case AuthenticationException _: return StatusCodes.Status401Unauthorized;
-                case SecurityException _: return StatusCodes.Status403Forbidden;
-                case DirectoryNotFoundException _:
-                case FileNotFoundException _:
-                case ResourceNotFoundException _: return StatusCodes.Status404NotFound;
-                case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed;
-                default: return StatusCodes.Status500InternalServerError;
+                return GetActualException(inners[0]);
             }
         }
 
-        private string NormalizeExceptionMessage(string msg)
+        return ex;
+    }
+
+    private static int GetStatusCode(Exception ex)
+    {
+        switch (ex)
         {
-            // Strip any information we don't want to reveal
-            return msg.Replace(
-                    _configuration.ApplicationPaths.ProgramSystemPath,
-                    string.Empty,
-                    StringComparison.OrdinalIgnoreCase)
-                .Replace(
-                    _configuration.ApplicationPaths.ProgramDataPath,
-                    string.Empty,
-                    StringComparison.OrdinalIgnoreCase);
+            case ArgumentException _: return StatusCodes.Status400BadRequest;
+            case AuthenticationException _: return StatusCodes.Status401Unauthorized;
+            case SecurityException _: return StatusCodes.Status403Forbidden;
+            case DirectoryNotFoundException _:
+            case FileNotFoundException _:
+            case ResourceNotFoundException _: return StatusCodes.Status404NotFound;
+            case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed;
+            default: return StatusCodes.Status500InternalServerError;
         }
     }
+
+    private string NormalizeExceptionMessage(string msg)
+    {
+        // Strip any information we don't want to reveal
+        return msg.Replace(
+                _configuration.ApplicationPaths.ProgramSystemPath,
+                string.Empty,
+                StringComparison.OrdinalIgnoreCase)
+            .Replace(
+                _configuration.ApplicationPaths.ProgramDataPath,
+                string.Empty,
+                StringComparison.OrdinalIgnoreCase);
+    }
 }

+ 31 - 32
Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs

@@ -4,47 +4,46 @@ using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
 using Microsoft.AspNetCore.Http;
 
-namespace Jellyfin.Api.Middleware
+namespace Jellyfin.Api.Middleware;
+
+/// <summary>
+/// Validates the IP of requests coming from local networks wrt. remote access.
+/// </summary>
+public class IpBasedAccessValidationMiddleware
 {
+    private readonly RequestDelegate _next;
+
     /// <summary>
-    /// Validates the IP of requests coming from local networks wrt. remote access.
+    /// Initializes a new instance of the <see cref="IpBasedAccessValidationMiddleware"/> class.
     /// </summary>
-    public class IpBasedAccessValidationMiddleware
+    /// <param name="next">The next delegate in the pipeline.</param>
+    public IpBasedAccessValidationMiddleware(RequestDelegate next)
     {
-        private readonly RequestDelegate _next;
+        _next = next;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="IpBasedAccessValidationMiddleware"/> class.
-        /// </summary>
-        /// <param name="next">The next delegate in the pipeline.</param>
-        public IpBasedAccessValidationMiddleware(RequestDelegate next)
+    /// <summary>
+    /// Executes the middleware action.
+    /// </summary>
+    /// <param name="httpContext">The current HTTP context.</param>
+    /// <param name="networkManager">The network manager.</param>
+    /// <returns>The async task.</returns>
+    public async Task Invoke(HttpContext httpContext, INetworkManager networkManager)
+    {
+        if (httpContext.IsLocal())
         {
-            _next = next;
+            // Running locally.
+            await _next(httpContext).ConfigureAwait(false);
+            return;
         }
 
-        /// <summary>
-        /// Executes the middleware action.
-        /// </summary>
-        /// <param name="httpContext">The current HTTP context.</param>
-        /// <param name="networkManager">The network manager.</param>
-        /// <returns>The async task.</returns>
-        public async Task Invoke(HttpContext httpContext, INetworkManager networkManager)
-        {
-            if (httpContext.IsLocal())
-            {
-                // Running locally.
-                await _next(httpContext).ConfigureAwait(false);
-                return;
-            }
-
-            var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback;
-
-            if (!networkManager.HasRemoteAccess(remoteIp))
-            {
-                return;
-            }
+        var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback;
 
-            await _next(httpContext).ConfigureAwait(false);
+        if (!networkManager.HasRemoteAccess(remoteIp))
+        {
+            return;
         }
+
+        await _next(httpContext).ConfigureAwait(false);
     }
 }

+ 27 - 28
Jellyfin.Api/Middleware/LanFilteringMiddleware.cs

@@ -5,41 +5,40 @@ using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Configuration;
 using Microsoft.AspNetCore.Http;
 
-namespace Jellyfin.Api.Middleware
+namespace Jellyfin.Api.Middleware;
+
+/// <summary>
+/// Validates the LAN host IP based on application configuration.
+/// </summary>
+public class LanFilteringMiddleware
 {
+    private readonly RequestDelegate _next;
+
     /// <summary>
-    /// Validates the LAN host IP based on application configuration.
+    /// Initializes a new instance of the <see cref="LanFilteringMiddleware"/> class.
     /// </summary>
-    public class LanFilteringMiddleware
+    /// <param name="next">The next delegate in the pipeline.</param>
+    public LanFilteringMiddleware(RequestDelegate next)
     {
-        private readonly RequestDelegate _next;
+        _next = next;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="LanFilteringMiddleware"/> class.
-        /// </summary>
-        /// <param name="next">The next delegate in the pipeline.</param>
-        public LanFilteringMiddleware(RequestDelegate next)
-        {
-            _next = next;
-        }
+    /// <summary>
+    /// Executes the middleware action.
+    /// </summary>
+    /// <param name="httpContext">The current HTTP context.</param>
+    /// <param name="networkManager">The network manager.</param>
+    /// <param name="serverConfigurationManager">The server configuration manager.</param>
+    /// <returns>The async task.</returns>
+    public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager)
+    {
+        var host = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback;
 
-        /// <summary>
-        /// Executes the middleware action.
-        /// </summary>
-        /// <param name="httpContext">The current HTTP context.</param>
-        /// <param name="networkManager">The network manager.</param>
-        /// <param name="serverConfigurationManager">The server configuration manager.</param>
-        /// <returns>The async task.</returns>
-        public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager)
+        if (!networkManager.IsInLocalNetwork(host) && !serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess)
         {
-            var host = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback;
-
-            if (!networkManager.IsInLocalNetwork(host) && !serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess)
-            {
-                return;
-            }
-
-            await _next(httpContext).ConfigureAwait(false);
+            return;
         }
+
+        await _next(httpContext).ConfigureAwait(false);
     }
 }

+ 37 - 38
Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs

@@ -3,52 +3,51 @@ using System.Threading.Tasks;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Middleware
+namespace Jellyfin.Api.Middleware;
+
+/// <summary>
+/// Removes /emby and /mediabrowser from requested route.
+/// </summary>
+public class LegacyEmbyRouteRewriteMiddleware
 {
+    private const string EmbyPath = "/emby";
+    private const string MediabrowserPath = "/mediabrowser";
+
+    private readonly RequestDelegate _next;
+    private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger;
+
     /// <summary>
-    /// Removes /emby and /mediabrowser from requested route.
+    /// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class.
     /// </summary>
-    public class LegacyEmbyRouteRewriteMiddleware
+    /// <param name="next">The next delegate in the pipeline.</param>
+    /// <param name="logger">The logger.</param>
+    public LegacyEmbyRouteRewriteMiddleware(
+        RequestDelegate next,
+        ILogger<LegacyEmbyRouteRewriteMiddleware> logger)
     {
-        private const string EmbyPath = "/emby";
-        private const string MediabrowserPath = "/mediabrowser";
-
-        private readonly RequestDelegate _next;
-        private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger;
+        _next = next;
+        _logger = logger;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class.
-        /// </summary>
-        /// <param name="next">The next delegate in the pipeline.</param>
-        /// <param name="logger">The logger.</param>
-        public LegacyEmbyRouteRewriteMiddleware(
-            RequestDelegate next,
-            ILogger<LegacyEmbyRouteRewriteMiddleware> logger)
+    /// <summary>
+    /// Executes the middleware action.
+    /// </summary>
+    /// <param name="httpContext">The current HTTP context.</param>
+    /// <returns>The async task.</returns>
+    public async Task Invoke(HttpContext httpContext)
+    {
+        var localPath = httpContext.Request.Path.ToString();
+        if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase))
         {
-            _next = next;
-            _logger = logger;
+            httpContext.Request.Path = localPath[EmbyPath.Length..];
+            _logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath);
         }
-
-        /// <summary>
-        /// Executes the middleware action.
-        /// </summary>
-        /// <param name="httpContext">The current HTTP context.</param>
-        /// <returns>The async task.</returns>
-        public async Task Invoke(HttpContext httpContext)
+        else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase))
         {
-            var localPath = httpContext.Request.Path.ToString();
-            if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase))
-            {
-                httpContext.Request.Path = localPath[EmbyPath.Length..];
-                _logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath);
-            }
-            else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase))
-            {
-                httpContext.Request.Path = localPath[MediabrowserPath.Length..];
-                _logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath);
-            }
-
-            await _next(httpContext).ConfigureAwait(false);
+            httpContext.Request.Path = localPath[MediabrowserPath.Length..];
+            _logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath);
         }
+
+        await _next(httpContext).ConfigureAwait(false);
     }
 }

+ 24 - 25
Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs

@@ -2,38 +2,37 @@ using System.Threading.Tasks;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http.Features;
 
-namespace Jellyfin.Api.Middleware
+namespace Jellyfin.Api.Middleware;
+
+/// <summary>
+/// URL decodes the querystring before binding.
+/// </summary>
+public class QueryStringDecodingMiddleware
 {
+    private readonly RequestDelegate _next;
+
     /// <summary>
-    /// URL decodes the querystring before binding.
+    /// Initializes a new instance of the <see cref="QueryStringDecodingMiddleware"/> class.
     /// </summary>
-    public class QueryStringDecodingMiddleware
+    /// <param name="next">The next delegate in the pipeline.</param>
+    public QueryStringDecodingMiddleware(RequestDelegate next)
     {
-        private readonly RequestDelegate _next;
+        _next = next;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="QueryStringDecodingMiddleware"/> class.
-        /// </summary>
-        /// <param name="next">The next delegate in the pipeline.</param>
-        public QueryStringDecodingMiddleware(RequestDelegate next)
+    /// <summary>
+    /// Executes the middleware action.
+    /// </summary>
+    /// <param name="httpContext">The current HTTP context.</param>
+    /// <returns>The async task.</returns>
+    public async Task Invoke(HttpContext httpContext)
+    {
+        var feature = httpContext.Features.Get<IQueryFeature>();
+        if (feature is not null)
         {
-            _next = next;
+            httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(feature));
         }
 
-        /// <summary>
-        /// Executes the middleware action.
-        /// </summary>
-        /// <param name="httpContext">The current HTTP context.</param>
-        /// <returns>The async task.</returns>
-        public async Task Invoke(HttpContext httpContext)
-        {
-            var feature = httpContext.Features.Get<IQueryFeature>();
-            if (feature is not null)
-            {
-                httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(feature));
-            }
-
-            await _next(httpContext).ConfigureAwait(false);
-        }
+        await _next(httpContext).ConfigureAwait(false);
     }
 }

+ 47 - 48
Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs

@@ -7,63 +7,62 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http.Extensions;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Middleware
+namespace Jellyfin.Api.Middleware;
+
+/// <summary>
+/// Response time middleware.
+/// </summary>
+public class ResponseTimeMiddleware
 {
+    private const string ResponseHeaderResponseTime = "X-Response-Time-ms";
+
+    private readonly RequestDelegate _next;
+    private readonly ILogger<ResponseTimeMiddleware> _logger;
+
     /// <summary>
-    /// Response time middleware.
+    /// Initializes a new instance of the <see cref="ResponseTimeMiddleware"/> class.
     /// </summary>
-    public class ResponseTimeMiddleware
+    /// <param name="next">Next request delegate.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param>
+    public ResponseTimeMiddleware(
+        RequestDelegate next,
+        ILogger<ResponseTimeMiddleware> logger)
     {
-        private const string ResponseHeaderResponseTime = "X-Response-Time-ms";
-
-        private readonly RequestDelegate _next;
-        private readonly ILogger<ResponseTimeMiddleware> _logger;
+        _next = next;
+        _logger = logger;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ResponseTimeMiddleware"/> class.
-        /// </summary>
-        /// <param name="next">Next request delegate.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param>
-        public ResponseTimeMiddleware(
-            RequestDelegate next,
-            ILogger<ResponseTimeMiddleware> logger)
-        {
-            _next = next;
-            _logger = logger;
-        }
+    /// <summary>
+    /// Invoke request.
+    /// </summary>
+    /// <param name="context">Request context.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <returns>Task.</returns>
+    public async Task Invoke(HttpContext context, IServerConfigurationManager serverConfigurationManager)
+    {
+        var startTimestamp = Stopwatch.GetTimestamp();
 
-        /// <summary>
-        /// Invoke request.
-        /// </summary>
-        /// <param name="context">Request context.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <returns>Task.</returns>
-        public async Task Invoke(HttpContext context, IServerConfigurationManager serverConfigurationManager)
+        var enableWarning = serverConfigurationManager.Configuration.EnableSlowResponseWarning;
+        var warningThreshold = serverConfigurationManager.Configuration.SlowResponseThresholdMs;
+        context.Response.OnStarting(() =>
         {
-            var startTimestamp = Stopwatch.GetTimestamp();
-
-            var enableWarning = serverConfigurationManager.Configuration.EnableSlowResponseWarning;
-            var warningThreshold = serverConfigurationManager.Configuration.SlowResponseThresholdMs;
-            context.Response.OnStarting(() =>
+            var responseTime = Stopwatch.GetElapsedTime(startTimestamp);
+            var responseTimeMs = responseTime.TotalMilliseconds;
+            if (enableWarning && responseTimeMs > warningThreshold && _logger.IsEnabled(LogLevel.Debug))
             {
-                var responseTime = Stopwatch.GetElapsedTime(startTimestamp);
-                var responseTimeMs = responseTime.TotalMilliseconds;
-                if (enableWarning && responseTimeMs > warningThreshold && _logger.IsEnabled(LogLevel.Debug))
-                {
-                    _logger.LogDebug(
-                        "Slow HTTP Response from {Url} to {RemoteIp} in {Elapsed:g} with Status Code {StatusCode}",
-                        context.Request.GetDisplayUrl(),
-                        context.GetNormalizedRemoteIp(),
-                        responseTime,
-                        context.Response.StatusCode);
-                }
+                _logger.LogDebug(
+                    "Slow HTTP Response from {Url} to {RemoteIp} in {Elapsed:g} with Status Code {StatusCode}",
+                    context.Request.GetDisplayUrl(),
+                    context.GetNormalizedRemoteIp(),
+                    responseTime,
+                    context.Response.StatusCode);
+            }
 
-                context.Response.Headers[ResponseHeaderResponseTime] = responseTimeMs.ToString(CultureInfo.InvariantCulture);
-                return Task.CompletedTask;
-            });
+            context.Response.Headers[ResponseHeaderResponseTime] = responseTimeMs.ToString(CultureInfo.InvariantCulture);
+            return Task.CompletedTask;
+        });
 
-            // Call the next delegate/middleware in the pipeline
-            await this._next(context).ConfigureAwait(false);
-        }
+        // Call the next delegate/middleware in the pipeline
+        await this._next(context).ConfigureAwait(false);
     }
 }

+ 31 - 32
Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs

@@ -3,45 +3,44 @@ using System.Threading.Tasks;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Middleware
+namespace Jellyfin.Api.Middleware;
+
+/// <summary>
+/// Redirect requests to robots.txt to web/robots.txt.
+/// </summary>
+public class RobotsRedirectionMiddleware
 {
+    private readonly RequestDelegate _next;
+    private readonly ILogger<RobotsRedirectionMiddleware> _logger;
+
     /// <summary>
-    /// Redirect requests to robots.txt to web/robots.txt.
+    /// Initializes a new instance of the <see cref="RobotsRedirectionMiddleware"/> class.
     /// </summary>
-    public class RobotsRedirectionMiddleware
+    /// <param name="next">The next delegate in the pipeline.</param>
+    /// <param name="logger">The logger.</param>
+    public RobotsRedirectionMiddleware(
+        RequestDelegate next,
+        ILogger<RobotsRedirectionMiddleware> logger)
     {
-        private readonly RequestDelegate _next;
-        private readonly ILogger<RobotsRedirectionMiddleware> _logger;
+        _next = next;
+        _logger = logger;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="RobotsRedirectionMiddleware"/> class.
-        /// </summary>
-        /// <param name="next">The next delegate in the pipeline.</param>
-        /// <param name="logger">The logger.</param>
-        public RobotsRedirectionMiddleware(
-            RequestDelegate next,
-            ILogger<RobotsRedirectionMiddleware> logger)
+    /// <summary>
+    /// Executes the middleware action.
+    /// </summary>
+    /// <param name="httpContext">The current HTTP context.</param>
+    /// <returns>The async task.</returns>
+    public async Task Invoke(HttpContext httpContext)
+    {
+        var localPath = httpContext.Request.Path.ToString();
+        if (string.Equals(localPath, "/robots.txt", StringComparison.OrdinalIgnoreCase))
         {
-            _next = next;
-            _logger = logger;
+            _logger.LogDebug("Redirecting robots.txt request to web/robots.txt");
+            httpContext.Response.Redirect("web/robots.txt");
+            return;
         }
 
-        /// <summary>
-        /// Executes the middleware action.
-        /// </summary>
-        /// <param name="httpContext">The current HTTP context.</param>
-        /// <returns>The async task.</returns>
-        public async Task Invoke(HttpContext httpContext)
-        {
-            var localPath = httpContext.Request.Path.ToString();
-            if (string.Equals(localPath, "/robots.txt", StringComparison.OrdinalIgnoreCase))
-            {
-                _logger.LogDebug("Redirecting robots.txt request to web/robots.txt");
-                httpContext.Response.Redirect("web/robots.txt");
-                return;
-            }
-
-            await _next(httpContext).ConfigureAwait(false);
-        }
+        await _next(httpContext).ConfigureAwait(false);
     }
 }

+ 33 - 34
Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs

@@ -5,47 +5,46 @@ using MediaBrowser.Controller;
 using MediaBrowser.Model.Globalization;
 using Microsoft.AspNetCore.Http;
 
-namespace Jellyfin.Api.Middleware
+namespace Jellyfin.Api.Middleware;
+
+/// <summary>
+/// Shows a custom message during server startup.
+/// </summary>
+public class ServerStartupMessageMiddleware
 {
+    private readonly RequestDelegate _next;
+
     /// <summary>
-    /// Shows a custom message during server startup.
+    /// Initializes a new instance of the <see cref="ServerStartupMessageMiddleware"/> class.
     /// </summary>
-    public class ServerStartupMessageMiddleware
+    /// <param name="next">The next delegate in the pipeline.</param>
+    public ServerStartupMessageMiddleware(RequestDelegate next)
     {
-        private readonly RequestDelegate _next;
+        _next = next;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ServerStartupMessageMiddleware"/> class.
-        /// </summary>
-        /// <param name="next">The next delegate in the pipeline.</param>
-        public ServerStartupMessageMiddleware(RequestDelegate next)
+    /// <summary>
+    /// Executes the middleware action.
+    /// </summary>
+    /// <param name="httpContext">The current HTTP context.</param>
+    /// <param name="serverApplicationHost">The server application host.</param>
+    /// <param name="localizationManager">The localization manager.</param>
+    /// <returns>The async task.</returns>
+    public async Task Invoke(
+        HttpContext httpContext,
+        IServerApplicationHost serverApplicationHost,
+        ILocalizationManager localizationManager)
+    {
+        if (serverApplicationHost.CoreStartupHasCompleted
+            || httpContext.Request.Path.Equals("/system/ping", StringComparison.OrdinalIgnoreCase))
         {
-            _next = next;
+            await _next(httpContext).ConfigureAwait(false);
+            return;
         }
 
-        /// <summary>
-        /// Executes the middleware action.
-        /// </summary>
-        /// <param name="httpContext">The current HTTP context.</param>
-        /// <param name="serverApplicationHost">The server application host.</param>
-        /// <param name="localizationManager">The localization manager.</param>
-        /// <returns>The async task.</returns>
-        public async Task Invoke(
-            HttpContext httpContext,
-            IServerApplicationHost serverApplicationHost,
-            ILocalizationManager localizationManager)
-        {
-            if (serverApplicationHost.CoreStartupHasCompleted
-                || httpContext.Request.Path.Equals("/system/ping", StringComparison.OrdinalIgnoreCase))
-            {
-                await _next(httpContext).ConfigureAwait(false);
-                return;
-            }
-
-            var message = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading");
-            httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
-            httpContext.Response.ContentType = MediaTypeNames.Text.Html;
-            await httpContext.Response.WriteAsync(message, httpContext.RequestAborted).ConfigureAwait(false);
-        }
+        var message = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading");
+        httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
+        httpContext.Response.ContentType = MediaTypeNames.Text.Html;
+        await httpContext.Response.WriteAsync(message, httpContext.RequestAborted).ConfigureAwait(false);
     }
 }

+ 51 - 52
Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs

@@ -6,79 +6,78 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http.Features;
 using Microsoft.Extensions.Primitives;
 
-namespace Jellyfin.Api.Middleware
+namespace Jellyfin.Api.Middleware;
+
+/// <summary>
+/// Defines the <see cref="UrlDecodeQueryFeature"/>.
+/// </summary>
+public class UrlDecodeQueryFeature : IQueryFeature
 {
+    private IQueryCollection? _store;
+
     /// <summary>
-    /// Defines the <see cref="UrlDecodeQueryFeature"/>.
+    /// Initializes a new instance of the <see cref="UrlDecodeQueryFeature"/> class.
     /// </summary>
-    public class UrlDecodeQueryFeature : IQueryFeature
+    /// <param name="feature">The <see cref="IQueryFeature"/> instance.</param>
+    public UrlDecodeQueryFeature(IQueryFeature feature)
     {
-        private IQueryCollection? _store;
+        Query = feature.Query;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="UrlDecodeQueryFeature"/> class.
-        /// </summary>
-        /// <param name="feature">The <see cref="IQueryFeature"/> instance.</param>
-        public UrlDecodeQueryFeature(IQueryFeature feature)
+    /// <summary>
+    /// Gets or sets a value indicating the url decoded <see cref="IQueryCollection"/>.
+    /// </summary>
+    public IQueryCollection Query
+    {
+        get
         {
-            Query = feature.Query;
+            return _store ?? QueryCollection.Empty;
         }
 
-        /// <summary>
-        /// Gets or sets a value indicating the url decoded <see cref="IQueryCollection"/>.
-        /// </summary>
-        public IQueryCollection Query
+        set
         {
-            get
+            // Only interested in where the querystring is encoded which shows up as one key with nothing in the value.
+            if (value.Count != 1)
             {
-                return _store ?? QueryCollection.Empty;
+                _store = value;
+                return;
             }
 
-            set
+            // Encoded querystrings have no value, so don't process anything if a value is present.
+            var (key, stringValues) = value.First();
+            if (!string.IsNullOrEmpty(stringValues))
             {
-                // Only interested in where the querystring is encoded which shows up as one key with nothing in the value.
-                if (value.Count != 1)
-                {
-                    _store = value;
-                    return;
-                }
+                _store = value;
+                return;
+            }
 
-                // Encoded querystrings have no value, so don't process anything if a value is present.
-                var (key, stringValues) = value.First();
-                if (!string.IsNullOrEmpty(stringValues))
-                {
-                    _store = value;
-                    return;
-                }
+            if (!key.Contains('=', StringComparison.Ordinal))
+            {
+                _store = value;
+                return;
+            }
 
-                if (!key.Contains('=', StringComparison.Ordinal))
+            var pairs = new Dictionary<string, StringValues>();
+            foreach (var pair in key.SpanSplit('&'))
+            {
+                var i = pair.IndexOf('=');
+                if (i == -1)
                 {
-                    _store = value;
-                    return;
+                    // encoded is an equals.
+                    // We use TryAdd so duplicate keys get ignored
+                    pairs.TryAdd(pair.ToString(), StringValues.Empty);
+                    continue;
                 }
 
-                var pairs = new Dictionary<string, StringValues>();
-                foreach (var pair in key.SpanSplit('&'))
+                var k = pair[..i].ToString();
+                var v = pair[(i + 1)..].ToString();
+                if (!pairs.TryAdd(k, new StringValues(v)))
                 {
-                    var i = pair.IndexOf('=');
-                    if (i == -1)
-                    {
-                        // encoded is an equals.
-                        // We use TryAdd so duplicate keys get ignored
-                        pairs.TryAdd(pair.ToString(), StringValues.Empty);
-                        continue;
-                    }
-
-                    var k = pair[..i].ToString();
-                    var v = pair[(i + 1)..].ToString();
-                    if (!pairs.TryAdd(k, new StringValues(v)))
-                    {
-                        pairs[k] = StringValues.Concat(pairs[k], v);
-                    }
+                    pairs[k] = StringValues.Concat(pairs[k], v);
                 }
-
-                _store = new QueryCollection(pairs);
             }
+
+            _store = new QueryCollection(pairs);
         }
     }
 }

+ 25 - 26
Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs

@@ -2,39 +2,38 @@ using System.Threading.Tasks;
 using MediaBrowser.Controller.Net;
 using Microsoft.AspNetCore.Http;
 
-namespace Jellyfin.Api.Middleware
+namespace Jellyfin.Api.Middleware;
+
+/// <summary>
+/// Handles WebSocket requests.
+/// </summary>
+public class WebSocketHandlerMiddleware
 {
+    private readonly RequestDelegate _next;
+
     /// <summary>
-    /// Handles WebSocket requests.
+    /// Initializes a new instance of the <see cref="WebSocketHandlerMiddleware"/> class.
     /// </summary>
-    public class WebSocketHandlerMiddleware
+    /// <param name="next">The next delegate in the pipeline.</param>
+    public WebSocketHandlerMiddleware(RequestDelegate next)
     {
-        private readonly RequestDelegate _next;
+        _next = next;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="WebSocketHandlerMiddleware"/> class.
-        /// </summary>
-        /// <param name="next">The next delegate in the pipeline.</param>
-        public WebSocketHandlerMiddleware(RequestDelegate next)
+    /// <summary>
+    /// Executes the middleware action.
+    /// </summary>
+    /// <param name="httpContext">The current HTTP context.</param>
+    /// <param name="webSocketManager">The WebSocket connection manager.</param>
+    /// <returns>The async task.</returns>
+    public async Task Invoke(HttpContext httpContext, IWebSocketManager webSocketManager)
+    {
+        if (!httpContext.WebSockets.IsWebSocketRequest)
         {
-            _next = next;
+            await _next(httpContext).ConfigureAwait(false);
+            return;
         }
 
-        /// <summary>
-        /// Executes the middleware action.
-        /// </summary>
-        /// <param name="httpContext">The current HTTP context.</param>
-        /// <param name="webSocketManager">The WebSocket connection manager.</param>
-        /// <returns>The async task.</returns>
-        public async Task Invoke(HttpContext httpContext, IWebSocketManager webSocketManager)
-        {
-            if (!httpContext.WebSockets.IsWebSocketRequest)
-            {
-                await _next(httpContext).ConfigureAwait(false);
-                return;
-            }
-
-            await webSocketManager.WebSocketRequestHandler(httpContext).ConfigureAwait(false);
-        }
+        await webSocketManager.WebSocketRequestHandler(httpContext).ConfigureAwait(false);
     }
 }

+ 56 - 57
Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs

@@ -5,86 +5,85 @@ using System.Threading.Tasks;
 using Microsoft.AspNetCore.Mvc.ModelBinding;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.ModelBinders
+namespace Jellyfin.Api.ModelBinders;
+
+/// <summary>
+/// Comma delimited array model binder.
+/// Returns an empty array of specified type if there is no query parameter.
+/// </summary>
+public class CommaDelimitedArrayModelBinder : IModelBinder
 {
+    private readonly ILogger<CommaDelimitedArrayModelBinder> _logger;
+
     /// <summary>
-    /// Comma delimited array model binder.
-    /// Returns an empty array of specified type if there is no query parameter.
+    /// Initializes a new instance of the <see cref="CommaDelimitedArrayModelBinder"/> class.
     /// </summary>
-    public class CommaDelimitedArrayModelBinder : IModelBinder
+    /// <param name="logger">Instance of the <see cref="ILogger{CommaDelimitedArrayModelBinder}"/> interface.</param>
+    public CommaDelimitedArrayModelBinder(ILogger<CommaDelimitedArrayModelBinder> logger)
+    {
+        _logger = logger;
+    }
+
+    /// <inheritdoc/>
+    public Task BindModelAsync(ModelBindingContext bindingContext)
     {
-        private readonly ILogger<CommaDelimitedArrayModelBinder> _logger;
+        var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+        var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
+        var converter = TypeDescriptor.GetConverter(elementType);
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CommaDelimitedArrayModelBinder"/> class.
-        /// </summary>
-        /// <param name="logger">Instance of the <see cref="ILogger{CommaDelimitedArrayModelBinder}"/> interface.</param>
-        public CommaDelimitedArrayModelBinder(ILogger<CommaDelimitedArrayModelBinder> logger)
+        if (valueProviderResult.Length > 1)
         {
-            _logger = logger;
+            var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter);
+            bindingContext.Result = ModelBindingResult.Success(typedValues);
         }
-
-        /// <inheritdoc/>
-        public Task BindModelAsync(ModelBindingContext bindingContext)
+        else
         {
-            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
-            var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
-            var converter = TypeDescriptor.GetConverter(elementType);
+            var value = valueProviderResult.FirstValue;
 
-            if (valueProviderResult.Length > 1)
+            if (value is not null)
             {
-                var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter);
+                var splitValues = value.Split(',', StringSplitOptions.RemoveEmptyEntries);
+                var typedValues = GetParsedResult(splitValues, elementType, converter);
                 bindingContext.Result = ModelBindingResult.Success(typedValues);
             }
             else
             {
-                var value = valueProviderResult.FirstValue;
-
-                if (value is not null)
-                {
-                    var splitValues = value.Split(',', StringSplitOptions.RemoveEmptyEntries);
-                    var typedValues = GetParsedResult(splitValues, elementType, converter);
-                    bindingContext.Result = ModelBindingResult.Success(typedValues);
-                }
-                else
-                {
-                    var emptyResult = Array.CreateInstance(elementType, 0);
-                    bindingContext.Result = ModelBindingResult.Success(emptyResult);
-                }
+                var emptyResult = Array.CreateInstance(elementType, 0);
+                bindingContext.Result = ModelBindingResult.Success(emptyResult);
             }
-
-            return Task.CompletedTask;
         }
 
-        private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter)
+        return Task.CompletedTask;
+    }
+
+    private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter)
+    {
+        var parsedValues = new object?[values.Count];
+        var convertedCount = 0;
+        for (var i = 0; i < values.Count; i++)
         {
-            var parsedValues = new object?[values.Count];
-            var convertedCount = 0;
-            for (var i = 0; i < values.Count; i++)
+            try
             {
-                try
-                {
-                    parsedValues[i] = converter.ConvertFromString(values[i].Trim());
-                    convertedCount++;
-                }
-                catch (FormatException e)
-                {
-                    _logger.LogDebug(e, "Error converting value.");
-                }
+                parsedValues[i] = converter.ConvertFromString(values[i].Trim());
+                convertedCount++;
             }
-
-            var typedValues = Array.CreateInstance(elementType, convertedCount);
-            var typedValueIndex = 0;
-            for (var i = 0; i < parsedValues.Length; i++)
+            catch (FormatException e)
             {
-                if (parsedValues[i] != null)
-                {
-                    typedValues.SetValue(parsedValues[i], typedValueIndex);
-                    typedValueIndex++;
-                }
+                _logger.LogDebug(e, "Error converting value.");
             }
+        }
 
-            return typedValues;
+        var typedValues = Array.CreateInstance(elementType, convertedCount);
+        var typedValueIndex = 0;
+        for (var i = 0; i < parsedValues.Length; i++)
+        {
+            if (parsedValues[i] != null)
+            {
+                typedValues.SetValue(parsedValues[i], typedValueIndex);
+                typedValueIndex++;
+            }
         }
+
+        return typedValues;
     }
 }

Some files were not shown because too many files changed in this diff