瀏覽代碼

Merge pull request #3812 from jellyfin/api-migration

Merge API Migration into master
Anthony Lavado 4 年之前
父節點
當前提交
b9fdbaeef3
共有 100 個文件被更改,包括 21350 次插入1524 次删除
  1. 0 386
      Emby.Dlna/Api/DlnaServerService.cs
  2. 0 88
      Emby.Dlna/Api/DlnaService.cs
  3. 3 3
      Emby.Dlna/Main/DlnaEntryPoint.cs
  4. 0 191
      Emby.Notifications/Api/NotificationsService.cs
  5. 3 9
      Emby.Server.Implementations/ApplicationHost.cs
  6. 3 1
      Emby.Server.Implementations/Browser/BrowserLauncher.cs
  7. 0 2
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  8. 14 20
      Emby.Server.Implementations/HttpServer/Security/AuthService.cs
  9. 7 6
      Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
  10. 0 287
      Emby.Server.Implementations/Services/SwaggerService.cs
  11. 35 0
      Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs
  12. 35 0
      Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs
  13. 103 0
      Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
  14. 13 2
      Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
  15. 42 0
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
  16. 11 0
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs
  17. 44 0
      Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs
  18. 11 0
      Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs
  19. 57 0
      Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs
  20. 11 0
      Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs
  21. 56 0
      Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs
  22. 11 0
      Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs
  23. 18 4
      Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs
  24. 42 0
      Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs
  25. 11 0
      Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs
  26. 45 0
      Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs
  27. 11 0
      Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs
  28. 44 0
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
  29. 11 0
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs
  30. 24 2
      Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs
  31. 38 0
      Jellyfin.Api/Constants/InternalClaimTypes.cs
  32. 36 1
      Jellyfin.Api/Constants/Policies.cs
  33. 57 0
      Jellyfin.Api/Controllers/ActivityLogController.cs
  34. 134 0
      Jellyfin.Api/Controllers/AlbumsController.cs
  35. 97 0
      Jellyfin.Api/Controllers/ApiKeyController.cs
  36. 488 0
      Jellyfin.Api/Controllers/ArtistsController.cs
  37. 353 0
      Jellyfin.Api/Controllers/AudioController.cs
  38. 57 0
      Jellyfin.Api/Controllers/BrandingController.cs
  39. 256 0
      Jellyfin.Api/Controllers/ChannelsController.cs
  40. 111 0
      Jellyfin.Api/Controllers/CollectionController.cs
  41. 126 0
      Jellyfin.Api/Controllers/ConfigurationController.cs
  42. 271 0
      Jellyfin.Api/Controllers/DashboardController.cs
  43. 155 0
      Jellyfin.Api/Controllers/DevicesController.cs
  44. 176 0
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  45. 132 0
      Jellyfin.Api/Controllers/DlnaController.cs
  46. 257 0
      Jellyfin.Api/Controllers/DlnaServerController.cs
  47. 2216 0
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  48. 191 0
      Jellyfin.Api/Controllers/EnvironmentController.cs
  49. 218 0
      Jellyfin.Api/Controllers/FilterController.cs
  50. 320 0
      Jellyfin.Api/Controllers/GenresController.cs
  51. 154 0
      Jellyfin.Api/Controllers/HlsSegmentController.cs
  52. 231 0
      Jellyfin.Api/Controllers/ImageByNameController.cs
  53. 1304 0
      Jellyfin.Api/Controllers/ImageController.cs
  54. 330 0
      Jellyfin.Api/Controllers/InstantMixController.cs
  55. 364 0
      Jellyfin.Api/Controllers/ItemLookupController.cs
  56. 85 0
      Jellyfin.Api/Controllers/ItemRefreshController.cs
  57. 239 187
      Jellyfin.Api/Controllers/ItemUpdateController.cs
  58. 593 0
      Jellyfin.Api/Controllers/ItemsController.cs
  59. 1036 0
      Jellyfin.Api/Controllers/LibraryController.cs
  60. 331 0
      Jellyfin.Api/Controllers/LibraryStructureController.cs
  61. 1238 0
      Jellyfin.Api/Controllers/LiveTvController.cs
  62. 76 0
      Jellyfin.Api/Controllers/LocalizationController.cs
  63. 337 239
      Jellyfin.Api/Controllers/MediaInfoController.cs
  64. 330 0
      Jellyfin.Api/Controllers/MoviesController.cs
  65. 313 0
      Jellyfin.Api/Controllers/MusicGenresController.cs
  66. 146 0
      Jellyfin.Api/Controllers/NotificationsController.cs
  67. 151 0
      Jellyfin.Api/Controllers/PackageController.cs
  68. 285 0
      Jellyfin.Api/Controllers/PersonsController.cs
  69. 199 0
      Jellyfin.Api/Controllers/PlaylistsController.cs
  70. 367 0
      Jellyfin.Api/Controllers/PlaystateController.cs
  71. 200 0
      Jellyfin.Api/Controllers/PluginsController.cs
  72. 268 0
      Jellyfin.Api/Controllers/RemoteImageController.cs
  73. 161 0
      Jellyfin.Api/Controllers/ScheduledTasksController.cs
  74. 269 0
      Jellyfin.Api/Controllers/SearchController.cs
  75. 491 0
      Jellyfin.Api/Controllers/SessionController.cs
  76. 41 16
      Jellyfin.Api/Controllers/StartupController.cs
  77. 277 0
      Jellyfin.Api/Controllers/StudiosController.cs
  78. 350 0
      Jellyfin.Api/Controllers/SubtitleController.cs
  79. 88 0
      Jellyfin.Api/Controllers/SuggestionsController.cs
  80. 205 0
      Jellyfin.Api/Controllers/SyncPlayController.cs
  81. 219 0
      Jellyfin.Api/Controllers/SystemController.cs
  82. 39 0
      Jellyfin.Api/Controllers/TimeSyncController.cs
  83. 281 0
      Jellyfin.Api/Controllers/TrailersController.cs
  84. 385 0
      Jellyfin.Api/Controllers/TvShowsController.cs
  85. 329 0
      Jellyfin.Api/Controllers/UniversalAudioController.cs
  86. 541 0
      Jellyfin.Api/Controllers/UserController.cs
  87. 392 0
      Jellyfin.Api/Controllers/UserLibraryController.cs
  88. 149 0
      Jellyfin.Api/Controllers/UserViewsController.cs
  89. 81 0
      Jellyfin.Api/Controllers/VideoAttachmentsController.cs
  90. 503 0
      Jellyfin.Api/Controllers/VideoHlsController.cs
  91. 523 0
      Jellyfin.Api/Controllers/VideosController.cs
  92. 234 0
      Jellyfin.Api/Controllers/YearsController.cs
  93. 162 0
      Jellyfin.Api/Extensions/DtoExtensions.cs
  94. 75 0
      Jellyfin.Api/Helpers/ClaimHelpers.cs
  95. 138 0
      Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
  96. 6 7
      Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
  97. 95 0
      Jellyfin.Api/Helpers/HlsHelpers.cs
  98. 175 0
      Jellyfin.Api/Helpers/ProgressiveFileCopier.cs
  99. 181 0
      Jellyfin.Api/Helpers/RequestHelpers.cs
  100. 29 73
      Jellyfin.Api/Helpers/SimilarItemsHelper.cs

+ 0 - 386
Emby.Dlna/Api/DlnaServerService.cs

@@ -1,386 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Diagnostics.CodeAnalysis;
-using System.IO;
-using System.Text;
-using System.Threading.Tasks;
-using Emby.Dlna.Main;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Dlna;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Services;
-using Microsoft.AspNetCore.Http;
-
-namespace Emby.Dlna.Api
-{
-    [Route("/Dlna/{UuId}/description.xml", "GET", Summary = "Gets dlna server info")]
-    [Route("/Dlna/{UuId}/description", "GET", Summary = "Gets dlna server info")]
-    public class GetDescriptionXml
-    {
-        [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string UuId { get; set; }
-    }
-
-    [Route("/Dlna/{UuId}/contentdirectory/contentdirectory.xml", "GET", Summary = "Gets dlna content directory xml")]
-    [Route("/Dlna/{UuId}/contentdirectory/contentdirectory", "GET", Summary = "Gets dlna content directory xml")]
-    public class GetContentDirectory
-    {
-        [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string UuId { get; set; }
-    }
-
-    [Route("/Dlna/{UuId}/connectionmanager/connectionmanager.xml", "GET", Summary = "Gets dlna connection manager xml")]
-    [Route("/Dlna/{UuId}/connectionmanager/connectionmanager", "GET", Summary = "Gets dlna connection manager xml")]
-    public class GetConnnectionManager
-    {
-        [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string UuId { get; set; }
-    }
-
-    [Route("/Dlna/{UuId}/mediareceiverregistrar/mediareceiverregistrar.xml", "GET", Summary = "Gets dlna mediareceiverregistrar xml")]
-    [Route("/Dlna/{UuId}/mediareceiverregistrar/mediareceiverregistrar", "GET", Summary = "Gets dlna mediareceiverregistrar xml")]
-    public class GetMediaReceiverRegistrar
-    {
-        [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string UuId { get; set; }
-    }
-
-    [Route("/Dlna/{UuId}/contentdirectory/control", "POST", Summary = "Processes a control request")]
-    public class ProcessContentDirectoryControlRequest : IRequiresRequestStream
-    {
-        [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string UuId { get; set; }
-
-        public Stream RequestStream { get; set; }
-    }
-
-    [Route("/Dlna/{UuId}/connectionmanager/control", "POST", Summary = "Processes a control request")]
-    public class ProcessConnectionManagerControlRequest : IRequiresRequestStream
-    {
-        [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string UuId { get; set; }
-
-        public Stream RequestStream { get; set; }
-    }
-
-    [Route("/Dlna/{UuId}/mediareceiverregistrar/control", "POST", Summary = "Processes a control request")]
-    public class ProcessMediaReceiverRegistrarControlRequest : IRequiresRequestStream
-    {
-        [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string UuId { get; set; }
-
-        public Stream RequestStream { get; set; }
-    }
-
-    [Route("/Dlna/{UuId}/mediareceiverregistrar/events", "SUBSCRIBE", Summary = "Processes an event subscription request")]
-    [Route("/Dlna/{UuId}/mediareceiverregistrar/events", "UNSUBSCRIBE", Summary = "Processes an event subscription request")]
-    public class ProcessMediaReceiverRegistrarEventRequest
-    {
-        [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "SUBSCRIBE,UNSUBSCRIBE")]
-        public string UuId { get; set; }
-    }
-
-    [Route("/Dlna/{UuId}/contentdirectory/events", "SUBSCRIBE", Summary = "Processes an event subscription request")]
-    [Route("/Dlna/{UuId}/contentdirectory/events", "UNSUBSCRIBE", Summary = "Processes an event subscription request")]
-    public class ProcessContentDirectoryEventRequest
-    {
-        [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "SUBSCRIBE,UNSUBSCRIBE")]
-        public string UuId { get; set; }
-    }
-
-    [Route("/Dlna/{UuId}/connectionmanager/events", "SUBSCRIBE", Summary = "Processes an event subscription request")]
-    [Route("/Dlna/{UuId}/connectionmanager/events", "UNSUBSCRIBE", Summary = "Processes an event subscription request")]
-    public class ProcessConnectionManagerEventRequest
-    {
-        [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "SUBSCRIBE,UNSUBSCRIBE")]
-        public string UuId { get; set; }
-    }
-
-    [Route("/Dlna/{UuId}/icons/{Filename}", "GET", Summary = "Gets a server icon")]
-    [Route("/Dlna/icons/{Filename}", "GET", Summary = "Gets a server icon")]
-    public class GetIcon
-    {
-        [ApiMember(Name = "UuId", Description = "Server UuId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string UuId { get; set; }
-
-        [ApiMember(Name = "Filename", Description = "The icon filename", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Filename { get; set; }
-    }
-
-    public class DlnaServerService : IService
-    {
-        private const string XMLContentType = "text/xml; charset=UTF-8";
-
-        private readonly IDlnaManager _dlnaManager;
-        private readonly IHttpResultFactory _resultFactory;
-        private readonly IServerConfigurationManager _configurationManager;
-
-        public IRequest Request { get; set; }
-
-        private IContentDirectory ContentDirectory => DlnaEntryPoint.Current.ContentDirectory;
-
-        private IConnectionManager ConnectionManager => DlnaEntryPoint.Current.ConnectionManager;
-
-        private IMediaReceiverRegistrar MediaReceiverRegistrar => DlnaEntryPoint.Current.MediaReceiverRegistrar;
-
-        public DlnaServerService(
-            IDlnaManager dlnaManager,
-            IHttpResultFactory httpResultFactory,
-            IServerConfigurationManager configurationManager,
-            IHttpContextAccessor httpContextAccessor)
-        {
-            _dlnaManager = dlnaManager;
-            _resultFactory = httpResultFactory;
-            _configurationManager = configurationManager;
-            Request = httpContextAccessor?.HttpContext.GetServiceStackRequest() ?? throw new ArgumentNullException(nameof(httpContextAccessor));
-        }
-
-        private string GetHeader(string name)
-        {
-            return Request.Headers[name];
-        }
-
-        public object Get(GetDescriptionXml request)
-        {
-            var url = Request.AbsoluteUri;
-            var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase));
-            var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, request.UuId, serverAddress);
-
-            var cacheLength = TimeSpan.FromDays(1);
-            var cacheKey = Request.RawUrl.GetMD5();
-            var bytes = Encoding.UTF8.GetBytes(xml);
-
-            return _resultFactory.GetStaticResult(Request, cacheKey, null, cacheLength, XMLContentType, () => Task.FromResult<Stream>(new MemoryStream(bytes)));
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Get(GetContentDirectory request)
-        {
-            var xml = ContentDirectory.GetServiceXml();
-
-            return _resultFactory.GetResult(Request, xml, XMLContentType);
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Get(GetMediaReceiverRegistrar request)
-        {
-            var xml = MediaReceiverRegistrar.GetServiceXml();
-
-            return _resultFactory.GetResult(Request, xml, XMLContentType);
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Get(GetConnnectionManager request)
-        {
-            var xml = ConnectionManager.GetServiceXml();
-
-            return _resultFactory.GetResult(Request, xml, XMLContentType);
-        }
-
-        public async Task<object> Post(ProcessMediaReceiverRegistrarControlRequest request)
-        {
-            var response = await PostAsync(request.RequestStream, MediaReceiverRegistrar).ConfigureAwait(false);
-
-            return _resultFactory.GetResult(Request, response.Xml, XMLContentType);
-        }
-
-        public async Task<object> Post(ProcessContentDirectoryControlRequest request)
-        {
-            var response = await PostAsync(request.RequestStream, ContentDirectory).ConfigureAwait(false);
-
-            return _resultFactory.GetResult(Request, response.Xml, XMLContentType);
-        }
-
-        public async Task<object> Post(ProcessConnectionManagerControlRequest request)
-        {
-            var response = await PostAsync(request.RequestStream, ConnectionManager).ConfigureAwait(false);
-
-            return _resultFactory.GetResult(Request, response.Xml, XMLContentType);
-        }
-
-        private Task<ControlResponse> PostAsync(Stream requestStream, IUpnpService service)
-        {
-            var id = GetPathValue(2).ToString();
-
-            return service.ProcessControlRequestAsync(new ControlRequest
-            {
-                Headers = Request.Headers,
-                InputXml = requestStream,
-                TargetServerUuId = id,
-                RequestedUrl = Request.AbsoluteUri
-            });
-        }
-
-        // Copied from MediaBrowser.Api/BaseApiService.cs
-        // TODO: Remove code duplication
-        /// <summary>
-        /// Gets the path segment at the specified index.
-        /// </summary>
-        /// <param name="index">The index of the path segment.</param>
-        /// <returns>The path segment at the specified index.</returns>
-        /// <exception cref="IndexOutOfRangeException" >Path doesn't contain enough segments.</exception>
-        /// <exception cref="InvalidDataException" >Path doesn't start with the base url.</exception>
-        protected internal ReadOnlySpan<char> GetPathValue(int index)
-        {
-            static void ThrowIndexOutOfRangeException()
-                => throw new IndexOutOfRangeException("Path doesn't contain enough segments.");
-
-            static void ThrowInvalidDataException()
-                => throw new InvalidDataException("Path doesn't start with the base url.");
-
-            ReadOnlySpan<char> path = Request.PathInfo;
-
-            // Remove the protocol part from the url
-            int pos = path.LastIndexOf("://");
-            if (pos != -1)
-            {
-                path = path.Slice(pos + 3);
-            }
-
-            // Remove the query string
-            pos = path.LastIndexOf('?');
-            if (pos != -1)
-            {
-                path = path.Slice(0, pos);
-            }
-
-            // Remove the domain
-            pos = path.IndexOf('/');
-            if (pos != -1)
-            {
-                path = path.Slice(pos);
-            }
-
-            // Remove base url
-            string baseUrl = _configurationManager.Configuration.BaseUrl;
-            int baseUrlLen = baseUrl.Length;
-            if (baseUrlLen != 0)
-            {
-                if (path.StartsWith(baseUrl, StringComparison.OrdinalIgnoreCase))
-                {
-                    path = path.Slice(baseUrlLen);
-                }
-                else
-                {
-                    // The path doesn't start with the base url,
-                    // how did we get here?
-                    ThrowInvalidDataException();
-                }
-            }
-
-            // Remove leading /
-            path = path.Slice(1);
-
-            // Backwards compatibility
-            const string Emby = "emby/";
-            if (path.StartsWith(Emby, StringComparison.OrdinalIgnoreCase))
-            {
-                path = path.Slice(Emby.Length);
-            }
-
-            const string MediaBrowser = "mediabrowser/";
-            if (path.StartsWith(MediaBrowser, StringComparison.OrdinalIgnoreCase))
-            {
-                path = path.Slice(MediaBrowser.Length);
-            }
-
-            // Skip segments until we are at the right index
-            for (int i = 0; i < index; i++)
-            {
-                pos = path.IndexOf('/');
-                if (pos == -1)
-                {
-                    ThrowIndexOutOfRangeException();
-                }
-
-                path = path.Slice(pos + 1);
-            }
-
-            // Remove the rest
-            pos = path.IndexOf('/');
-            if (pos != -1)
-            {
-                path = path.Slice(0, pos);
-            }
-
-            return path;
-        }
-
-        public object Get(GetIcon request)
-        {
-            var contentType = "image/" + Path.GetExtension(request.Filename)
-                                            .TrimStart('.')
-                                            .ToLowerInvariant();
-
-            var cacheLength = TimeSpan.FromDays(365);
-            var cacheKey = Request.RawUrl.GetMD5();
-
-            return _resultFactory.GetStaticResult(Request, cacheKey, null, cacheLength, contentType, () => Task.FromResult(_dlnaManager.GetIcon(request.Filename).Stream));
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Subscribe(ProcessContentDirectoryEventRequest request)
-        {
-            return ProcessEventRequest(ContentDirectory);
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Subscribe(ProcessConnectionManagerEventRequest request)
-        {
-            return ProcessEventRequest(ConnectionManager);
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Subscribe(ProcessMediaReceiverRegistrarEventRequest request)
-        {
-            return ProcessEventRequest(MediaReceiverRegistrar);
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Unsubscribe(ProcessContentDirectoryEventRequest request)
-        {
-            return ProcessEventRequest(ContentDirectory);
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Unsubscribe(ProcessConnectionManagerEventRequest request)
-        {
-            return ProcessEventRequest(ConnectionManager);
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Unsubscribe(ProcessMediaReceiverRegistrarEventRequest request)
-        {
-            return ProcessEventRequest(MediaReceiverRegistrar);
-        }
-
-        private object ProcessEventRequest(IEventManager eventManager)
-        {
-            var subscriptionId = GetHeader("SID");
-
-            if (string.Equals(Request.Verb, "SUBSCRIBE", StringComparison.OrdinalIgnoreCase))
-            {
-                var notificationType = GetHeader("NT");
-
-                var callback = GetHeader("CALLBACK");
-                var timeoutString = GetHeader("TIMEOUT");
-
-                if (string.IsNullOrEmpty(notificationType))
-                {
-                    return GetSubscriptionResponse(eventManager.RenewEventSubscription(subscriptionId, notificationType, timeoutString, callback));
-                }
-
-                return GetSubscriptionResponse(eventManager.CreateEventSubscription(notificationType, timeoutString, callback));
-            }
-
-            return GetSubscriptionResponse(eventManager.CancelEventSubscription(subscriptionId));
-        }
-
-        private object GetSubscriptionResponse(EventSubscriptionResponse response)
-        {
-            return _resultFactory.GetResult(Request, response.Content, response.ContentType, response.Headers);
-        }
-    }
-}

+ 0 - 88
Emby.Dlna/Api/DlnaService.cs

@@ -1,88 +0,0 @@
-#pragma warning disable CS1591
-
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-using MediaBrowser.Controller.Dlna;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Dlna;
-using MediaBrowser.Model.Services;
-
-namespace Emby.Dlna.Api
-{
-    [Route("/Dlna/ProfileInfos", "GET", Summary = "Gets a list of profiles")]
-    public class GetProfileInfos : IReturn<DeviceProfileInfo[]>
-    {
-    }
-
-    [Route("/Dlna/Profiles/{Id}", "DELETE", Summary = "Deletes a profile")]
-    public class DeleteProfile : IReturnVoid
-    {
-        [ApiMember(Name = "Id", Description = "Profile Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Dlna/Profiles/Default", "GET", Summary = "Gets the default profile")]
-    public class GetDefaultProfile : IReturn<DeviceProfile>
-    {
-    }
-
-    [Route("/Dlna/Profiles/{Id}", "GET", Summary = "Gets a single profile")]
-    public class GetProfile : IReturn<DeviceProfile>
-    {
-        [ApiMember(Name = "Id", Description = "Profile Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Dlna/Profiles/{Id}", "POST", Summary = "Updates a profile")]
-    public class UpdateProfile : DeviceProfile, IReturnVoid
-    {
-    }
-
-    [Route("/Dlna/Profiles", "POST", Summary = "Creates a profile")]
-    public class CreateProfile : DeviceProfile, IReturnVoid
-    {
-    }
-
-    [Authenticated(Roles = "Admin")]
-    public class DlnaService : IService
-    {
-        private readonly IDlnaManager _dlnaManager;
-
-        public DlnaService(IDlnaManager dlnaManager)
-        {
-            _dlnaManager = dlnaManager;
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Get(GetProfileInfos request)
-        {
-            return _dlnaManager.GetProfileInfos().ToArray();
-        }
-
-        public object Get(GetProfile request)
-        {
-            return _dlnaManager.GetProfile(request.Id);
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Get(GetDefaultProfile request)
-        {
-            return _dlnaManager.GetDefaultProfile();
-        }
-
-        public void Delete(DeleteProfile request)
-        {
-            _dlnaManager.DeleteProfile(request.Id);
-        }
-
-        public void Post(UpdateProfile request)
-        {
-            _dlnaManager.UpdateProfile(request);
-        }
-
-        public void Post(CreateProfile request)
-        {
-            _dlnaManager.CreateProfile(request);
-        }
-    }
-}

+ 3 - 3
Emby.Dlna/Main/DlnaEntryPoint.cs

@@ -54,11 +54,11 @@ namespace Emby.Dlna.Main
         private SsdpDevicePublisher _publisher;
         private ISsdpCommunicationsServer _communicationsServer;
 
-        internal IContentDirectory ContentDirectory { get; private set; }
+        public IContentDirectory ContentDirectory { get; private set; }
 
-        internal IConnectionManager ConnectionManager { get; private set; }
+        public IConnectionManager ConnectionManager { get; private set; }
 
-        internal IMediaReceiverRegistrar MediaReceiverRegistrar { get; private set; }
+        public IMediaReceiverRegistrar MediaReceiverRegistrar { get; private set; }
 
         public static DlnaEntryPoint Current;
 

+ 0 - 191
Emby.Notifications/Api/NotificationsService.cs

@@ -1,191 +0,0 @@
-#pragma warning disable CS1591
-#pragma warning disable SA1402
-#pragma warning disable SA1649
-
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Jellyfin.Data.Enums;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Notifications;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Notifications;
-using MediaBrowser.Model.Services;
-
-namespace Emby.Notifications.Api
-{
-    [Route("/Notifications/{UserId}", "GET", Summary = "Gets notifications")]
-    public class GetNotifications : IReturn<NotificationResult>
-    {
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string UserId { get; set; } = string.Empty;
-
-        [ApiMember(Name = "IsRead", Description = "An optional filter by IsRead", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
-        public bool? IsRead { get; set; }
-
-        [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? StartIndex { get; set; }
-
-        [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? Limit { get; set; }
-    }
-
-    public class Notification
-    {
-        public string Id { get; set; } = string.Empty;
-
-        public string UserId { get; set; } = string.Empty;
-
-        public DateTime Date { get; set; }
-
-        public bool IsRead { get; set; }
-
-        public string Name { get; set; } = string.Empty;
-
-        public string Description { get; set; } = string.Empty;
-
-        public string Url { get; set; } = string.Empty;
-
-        public NotificationLevel Level { get; set; }
-    }
-
-    public class NotificationResult
-    {
-        public IReadOnlyList<Notification> Notifications { get; set; } = Array.Empty<Notification>();
-
-        public int TotalRecordCount { get; set; }
-    }
-
-    public class NotificationsSummary
-    {
-        public int UnreadCount { get; set; }
-
-        public NotificationLevel MaxUnreadNotificationLevel { get; set; }
-    }
-
-    [Route("/Notifications/{UserId}/Summary", "GET", Summary = "Gets a notification summary for a user")]
-    public class GetNotificationsSummary : IReturn<NotificationsSummary>
-    {
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string UserId { get; set; } = string.Empty;
-    }
-
-    [Route("/Notifications/Types", "GET", Summary = "Gets notification types")]
-    public class GetNotificationTypes : IReturn<List<NotificationTypeInfo>>
-    {
-    }
-
-    [Route("/Notifications/Services", "GET", Summary = "Gets notification types")]
-    public class GetNotificationServices : IReturn<List<NameIdPair>>
-    {
-    }
-
-    [Route("/Notifications/Admin", "POST", Summary = "Sends a notification to all admin users")]
-    public class AddAdminNotification : IReturnVoid
-    {
-        [ApiMember(Name = "Name", Description = "The notification's name", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string Name { get; set; } = string.Empty;
-
-        [ApiMember(Name = "Description", Description = "The notification's description", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string Description { get; set; } = string.Empty;
-
-        [ApiMember(Name = "ImageUrl", Description = "The notification's image url", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string? ImageUrl { get; set; }
-
-        [ApiMember(Name = "Url", Description = "The notification's info url", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string? Url { get; set; }
-
-        [ApiMember(Name = "Level", Description = "The notification level", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public NotificationLevel Level { get; set; }
-    }
-
-    [Route("/Notifications/{UserId}/Read", "POST", Summary = "Marks notifications as read")]
-    public class MarkRead : IReturnVoid
-    {
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string UserId { get; set; } = string.Empty;
-
-        [ApiMember(Name = "Ids", Description = "A list of notification ids, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)]
-        public string Ids { get; set; } = string.Empty;
-    }
-
-    [Route("/Notifications/{UserId}/Unread", "POST", Summary = "Marks notifications as unread")]
-    public class MarkUnread : IReturnVoid
-    {
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string UserId { get; set; } = string.Empty;
-
-        [ApiMember(Name = "Ids", Description = "A list of notification ids, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)]
-        public string Ids { get; set; } = string.Empty;
-    }
-
-    [Authenticated]
-    public class NotificationsService : IService
-    {
-        private readonly INotificationManager _notificationManager;
-        private readonly IUserManager _userManager;
-
-        public NotificationsService(INotificationManager notificationManager, IUserManager userManager)
-        {
-            _notificationManager = notificationManager;
-            _userManager = userManager;
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Get(GetNotificationTypes request)
-        {
-            return _notificationManager.GetNotificationTypes();
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Get(GetNotificationServices request)
-        {
-            return _notificationManager.GetNotificationServices().ToList();
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Get(GetNotificationsSummary request)
-        {
-            return new NotificationsSummary();
-        }
-
-        public Task Post(AddAdminNotification request)
-        {
-            // This endpoint really just exists as post of a real with sickbeard
-            var notification = new NotificationRequest
-            {
-                Date = DateTime.UtcNow,
-                Description = request.Description,
-                Level = request.Level,
-                Name = request.Name,
-                Url = request.Url,
-                UserIds = _userManager.Users
-                    .Where(user => user.HasPermission(PermissionKind.IsAdministrator))
-                    .Select(user => user.Id)
-                    .ToArray()
-            };
-
-            return _notificationManager.SendNotification(notification, CancellationToken.None);
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public void Post(MarkRead request)
-        {
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public void Post(MarkUnread request)
-        {
-        }
-
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
-        public object Get(GetNotifications request)
-        {
-            return new NotificationResult();
-        }
-    }
-}

+ 3 - 9
Emby.Server.Implementations/ApplicationHost.cs

@@ -4,7 +4,6 @@ using System;
 using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Diagnostics;
-using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Net;
@@ -46,7 +45,7 @@ using Emby.Server.Implementations.Session;
 using Emby.Server.Implementations.SyncPlay;
 using Emby.Server.Implementations.TV;
 using Emby.Server.Implementations.Updates;
-using MediaBrowser.Api;
+using Jellyfin.Api.Helpers;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Events;
@@ -97,7 +96,6 @@ using MediaBrowser.Providers.Chapters;
 using MediaBrowser.Providers.Manager;
 using MediaBrowser.Providers.Plugins.TheTvdb;
 using MediaBrowser.Providers.Subtitles;
-using MediaBrowser.WebDashboard.Api;
 using MediaBrowser.XbmcMetadata.Providers;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.DependencyInjection;
@@ -632,6 +630,8 @@ namespace Emby.Server.Implementations
             serviceCollection.AddSingleton<EncodingHelper>();
 
             serviceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>();
+
+            serviceCollection.AddSingleton<TranscodingJobHelper>();
         }
 
         /// <summary>
@@ -1031,12 +1031,6 @@ namespace Emby.Server.Implementations
                 }
             }
 
-            // Include composable parts in the Api assembly
-            yield return typeof(ApiEntryPoint).Assembly;
-
-            // Include composable parts in the Dashboard assembly
-            yield return typeof(DashboardService).Assembly;
-
             // Include composable parts in the Model assembly
             yield return typeof(SystemInfo).Assembly;
 

+ 3 - 1
Emby.Server.Implementations/Browser/BrowserLauncher.cs

@@ -1,5 +1,7 @@
 using System;
 using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.Browser
@@ -24,7 +26,7 @@ namespace Emby.Server.Implementations.Browser
         /// <param name="appHost">The app host.</param>
         public static void OpenSwaggerPage(IServerApplicationHost appHost)
         {
-            TryOpenUrl(appHost, "/swagger/index.html");
+            TryOpenUrl(appHost, "/api-docs/swagger");
         }
 
         /// <summary>

+ 0 - 2
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -13,10 +13,8 @@
     <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
     <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
     <ProjectReference Include="..\MediaBrowser.Providers\MediaBrowser.Providers.csproj" />
-    <ProjectReference Include="..\MediaBrowser.WebDashboard\MediaBrowser.WebDashboard.csproj" />
     <ProjectReference Include="..\MediaBrowser.XbmcMetadata\MediaBrowser.XbmcMetadata.csproj" />
     <ProjectReference Include="..\Emby.Dlna\Emby.Dlna.csproj" />
-    <ProjectReference Include="..\MediaBrowser.Api\MediaBrowser.Api.csproj" />
     <ProjectReference Include="..\MediaBrowser.LocalMetadata\MediaBrowser.LocalMetadata.csproj" />
     <ProjectReference Include="..\Emby.Photos\Emby.Photos.csproj" />
     <ProjectReference Include="..\Emby.Drawing\Emby.Drawing.csproj" />

+ 14 - 20
Emby.Server.Implementations/HttpServer/Security/AuthService.cs

@@ -35,9 +35,9 @@ namespace Emby.Server.Implementations.HttpServer.Security
             _networkManager = networkManager;
         }
 
-        public void Authenticate(IRequest request, IAuthenticationAttributes authAttribtues)
+        public void Authenticate(IRequest request, IAuthenticationAttributes authAttributes)
         {
-            ValidateUser(request, authAttribtues);
+            ValidateUser(request, authAttributes);
         }
 
         public User Authenticate(HttpRequest request, IAuthenticationAttributes authAttributes)
@@ -63,17 +63,17 @@ namespace Emby.Server.Implementations.HttpServer.Security
             return auth;
         }
 
-        private User ValidateUser(IRequest request, IAuthenticationAttributes authAttribtues)
+        private User ValidateUser(IRequest request, IAuthenticationAttributes authAttributes)
         {
             // This code is executed before the service
             var auth = _authorizationContext.GetAuthorizationInfo(request);
 
-            if (!IsExemptFromAuthenticationToken(authAttribtues, request))
+            if (!IsExemptFromAuthenticationToken(authAttributes, request))
             {
                 ValidateSecurityToken(request, auth.Token);
             }
 
-            if (authAttribtues.AllowLocalOnly && !request.IsLocal)
+            if (authAttributes.AllowLocalOnly && !request.IsLocal)
             {
                 throw new SecurityException("Operation not found.");
             }
@@ -87,14 +87,14 @@ namespace Emby.Server.Implementations.HttpServer.Security
 
             if (user != null)
             {
-                ValidateUserAccess(user, request, authAttribtues, auth);
+                ValidateUserAccess(user, request, authAttributes);
             }
 
             var info = GetTokenInfo(request);
 
-            if (!IsExemptFromRoles(auth, authAttribtues, request, info))
+            if (!IsExemptFromRoles(auth, authAttributes, request, info))
             {
-                var roles = authAttribtues.GetRoles();
+                var roles = authAttributes.GetRoles();
 
                 ValidateRoles(roles, user);
             }
@@ -118,8 +118,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
         private void ValidateUserAccess(
             User user,
             IRequest request,
-            IAuthenticationAttributes authAttributes,
-            AuthorizationInfo auth)
+            IAuthenticationAttributes authAttributes)
         {
             if (user.HasPermission(PermissionKind.IsDisabled))
             {
@@ -158,6 +157,11 @@ namespace Emby.Server.Implementations.HttpServer.Security
                 return true;
             }
 
+            if (authAttribtues.IgnoreLegacyAuth)
+            {
+                return true;
+            }
+
             return false;
         }
 
@@ -237,16 +241,6 @@ namespace Emby.Server.Implementations.HttpServer.Security
             {
                 throw new AuthenticationException("Access token is invalid or expired.");
             }
-
-            // if (!string.IsNullOrEmpty(info.UserId))
-            //{
-            //    var user = _userManager.GetUserById(info.UserId);
-
-            //    if (user == null || user.Configuration.IsDisabled)
-            //    {
-            //        throw new SecurityException("User account has been disabled.");
-            //    }
-            //}
         }
     }
 }

+ 7 - 6
Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs

@@ -97,6 +97,12 @@ namespace Emby.Server.Implementations.HttpServer.Security
                 token = headers["X-MediaBrowser-Token"];
             }
 
+            if (string.IsNullOrEmpty(token))
+            {
+                token = queryString["ApiKey"];
+            }
+
+            // TODO deprecate this query parameter.
             if (string.IsNullOrEmpty(token))
             {
                 token = queryString["api_key"];
@@ -276,12 +282,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
 
         private static string NormalizeValue(string value)
         {
-            if (string.IsNullOrEmpty(value))
-            {
-                return value;
-            }
-
-            return WebUtility.HtmlEncode(value);
+            return string.IsNullOrEmpty(value) ? value : WebUtility.HtmlEncode(value);
         }
     }
 }

+ 0 - 287
Emby.Server.Implementations/Services/SwaggerService.cs

@@ -1,287 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Emby.Server.Implementations.HttpServer;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Services;
-
-namespace Emby.Server.Implementations.Services
-{
-    [Route("/swagger", "GET", Summary = "Gets the swagger specifications")]
-    [Route("/swagger.json", "GET", Summary = "Gets the swagger specifications")]
-    public class GetSwaggerSpec : IReturn<SwaggerSpec>
-    {
-    }
-
-    public class SwaggerSpec
-    {
-        public string swagger { get; set; }
-
-        public string[] schemes { get; set; }
-
-        public SwaggerInfo info { get; set; }
-
-        public string host { get; set; }
-
-        public string basePath { get; set; }
-
-        public SwaggerTag[] tags { get; set; }
-
-        public IDictionary<string, Dictionary<string, SwaggerMethod>> paths { get; set; }
-
-        public Dictionary<string, SwaggerDefinition> definitions { get; set; }
-
-        public SwaggerComponents components { get; set; }
-    }
-
-    public class SwaggerComponents
-    {
-        public Dictionary<string, SwaggerSecurityScheme> securitySchemes { get; set; }
-    }
-
-    public class SwaggerSecurityScheme
-    {
-        public string name { get; set; }
-
-        public string type { get; set; }
-
-        public string @in { get; set; }
-    }
-
-    public class SwaggerInfo
-    {
-        public string description { get; set; }
-
-        public string version { get; set; }
-
-        public string title { get; set; }
-
-        public string termsOfService { get; set; }
-
-        public SwaggerConcactInfo contact { get; set; }
-    }
-
-    public class SwaggerConcactInfo
-    {
-        public string email { get; set; }
-
-        public string name { get; set; }
-
-        public string url { get; set; }
-    }
-
-    public class SwaggerTag
-    {
-        public string description { get; set; }
-
-        public string name { get; set; }
-    }
-
-    public class SwaggerMethod
-    {
-        public string summary { get; set; }
-
-        public string description { get; set; }
-
-        public string[] tags { get; set; }
-
-        public string operationId { get; set; }
-
-        public string[] consumes { get; set; }
-
-        public string[] produces { get; set; }
-
-        public SwaggerParam[] parameters { get; set; }
-
-        public Dictionary<string, SwaggerResponse> responses { get; set; }
-
-        public Dictionary<string, string[]>[] security { get; set; }
-    }
-
-    public class SwaggerParam
-    {
-        public string @in { get; set; }
-
-        public string name { get; set; }
-
-        public string description { get; set; }
-
-        public bool required { get; set; }
-
-        public string type { get; set; }
-
-        public string collectionFormat { get; set; }
-    }
-
-    public class SwaggerResponse
-    {
-        public string description { get; set; }
-
-        // ex. "$ref":"#/definitions/Pet"
-        public Dictionary<string, string> schema { get; set; }
-    }
-
-    public class SwaggerDefinition
-    {
-        public string type { get; set; }
-
-        public Dictionary<string, SwaggerProperty> properties { get; set; }
-    }
-
-    public class SwaggerProperty
-    {
-        public string type { get; set; }
-
-        public string format { get; set; }
-
-        public string description { get; set; }
-
-        public string[] @enum { get; set; }
-
-        public string @default { get; set; }
-    }
-
-    public class SwaggerService : IService, IRequiresRequest
-    {
-        private readonly IHttpServer _httpServer;
-        private SwaggerSpec _spec;
-
-        public IRequest Request { get; set; }
-
-        public SwaggerService(IHttpServer httpServer)
-        {
-            _httpServer = httpServer;
-        }
-
-        public object Get(GetSwaggerSpec request)
-        {
-            return _spec ?? (_spec = GetSpec());
-        }
-
-        private SwaggerSpec GetSpec()
-        {
-            string host = null;
-            Uri uri;
-            if (Uri.TryCreate(Request.RawUrl, UriKind.Absolute, out uri))
-            {
-                host = uri.Host;
-            }
-
-            var securitySchemes = new Dictionary<string, SwaggerSecurityScheme>();
-
-            securitySchemes["api_key"] = new SwaggerSecurityScheme
-            {
-                name = "api_key",
-                type = "apiKey",
-                @in = "query"
-            };
-
-            var spec = new SwaggerSpec
-            {
-                schemes = new[] { "http" },
-                tags = GetTags(),
-                swagger = "2.0",
-                info = new SwaggerInfo
-                {
-                    title = "Jellyfin Server API",
-                    version = "1.0.0",
-                    description = "Explore the Jellyfin Server API",
-                    contact = new SwaggerConcactInfo
-                    {
-                        name = "Jellyfin Community",
-                        url = "https://jellyfin.readthedocs.io/en/latest/user-docs/getting-help/"
-                    }
-                },
-                paths = GetPaths(),
-                definitions = GetDefinitions(),
-                basePath = "/jellyfin",
-                host = host,
-
-                components = new SwaggerComponents
-                {
-                    securitySchemes = securitySchemes
-                }
-            };
-
-            return spec;
-        }
-
-
-        private SwaggerTag[] GetTags()
-        {
-            return Array.Empty<SwaggerTag>();
-        }
-
-        private Dictionary<string, SwaggerDefinition> GetDefinitions()
-        {
-            return new Dictionary<string, SwaggerDefinition>();
-        }
-
-        private IDictionary<string, Dictionary<string, SwaggerMethod>> GetPaths()
-        {
-            var paths = new SortedDictionary<string, Dictionary<string, SwaggerMethod>>();
-
-            // REVIEW: this can be done better
-            var all = ((HttpListenerHost)_httpServer).ServiceController.RestPathMap.OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase).ToList();
-
-            foreach (var current in all)
-            {
-                foreach (var info in current.Value)
-                {
-                    if (info.IsHidden)
-                    {
-                        continue;
-                    }
-
-                    if (info.Path.StartsWith("/mediabrowser", StringComparison.OrdinalIgnoreCase)
-                        || info.Path.StartsWith("/jellyfin", StringComparison.OrdinalIgnoreCase))
-                    {
-                        continue;
-                    }
-
-                    paths[info.Path] = GetPathInfo(info);
-                }
-            }
-
-            return paths;
-        }
-
-        private Dictionary<string, SwaggerMethod> GetPathInfo(RestPath info)
-        {
-            var result = new Dictionary<string, SwaggerMethod>();
-
-            foreach (var verb in info.Verbs)
-            {
-                var responses = new Dictionary<string, SwaggerResponse>
-                {
-                    { "200", new SwaggerResponse { description = "OK" } }
-                };
-
-                var apiKeySecurity = new Dictionary<string, string[]>
-                {
-                    { "api_key",  Array.Empty<string>() }
-                };
-
-                result[verb.ToLowerInvariant()] = new SwaggerMethod
-                {
-                    summary = info.Summary,
-                    description = info.Description,
-                    produces = new[] { "application/json" },
-                    consumes = new[] { "application/json" },
-                    operationId = info.RequestType.Name,
-                    tags = Array.Empty<string>(),
-
-                    parameters = Array.Empty<SwaggerParam>(),
-
-                    responses = responses,
-
-                    security = new[] { apiKeySecurity }
-                };
-            }
-
-            return result;
-        }
-    }
-}

+ 35 - 0
Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs

@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Mvc.Routing;
+
+namespace Jellyfin.Api.Attributes
+{
+    /// <summary>
+    /// Identifies an action that supports the HTTP GET method.
+    /// </summary>
+    public class HttpSubscribeAttribute : HttpMethodAttribute
+    {
+        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)
+        {
+            if (template == null)
+            {
+                throw new ArgumentNullException(nameof(template));
+            }
+        }
+    }
+}

+ 35 - 0
Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs

@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Mvc.Routing;
+
+namespace Jellyfin.Api.Attributes
+{
+    /// <summary>
+    /// Identifies an action that supports the HTTP GET method.
+    /// </summary>
+    public class HttpUnsubscribeAttribute : HttpMethodAttribute
+    {
+        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)
+        {
+            if (template == null)
+            {
+                throw new ArgumentNullException(nameof(template));
+            }
+        }
+    }
+}

+ 103 - 0
Jellyfin.Api/Auth/BaseAuthorizationHandler.cs

@@ -0,0 +1,103 @@
+using System.Security.Claims;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth
+{
+    /// <summary>
+    /// Base authorization handler.
+    /// </summary>
+    /// <typeparam name="T">Type of Authorization Requirement.</typeparam>
+    public abstract class BaseAuthorizationHandler<T> : AuthorizationHandler<T>
+        where T : IAuthorizationRequirement
+    {
+        private readonly IUserManager _userManager;
+        private readonly INetworkManager _networkManager;
+        private readonly IHttpContextAccessor _httpContextAccessor;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="BaseAuthorizationHandler{T}"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        protected BaseAuthorizationHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+        {
+            _userManager = userManager;
+            _networkManager = networkManager;
+            _httpContextAccessor = httpContextAccessor;
+        }
+
+        /// <summary>
+        /// Validate authenticated claims.
+        /// </summary>
+        /// <param name="claimsPrincipal">Request claims.</param>
+        /// <param name="ignoreSchedule">Whether to ignore parental control.</param>
+        /// <param name="localAccessOnly">Whether access is to be allowed locally only.</param>
+        /// <param name="requiredDownloadPermission">Whether validation requires download permission.</param>
+        /// <returns>Validated claim status.</returns>
+        protected bool ValidateClaims(
+            ClaimsPrincipal claimsPrincipal,
+            bool ignoreSchedule = false,
+            bool localAccessOnly = false,
+            bool requiredDownloadPermission = false)
+        {
+            // Ensure claim has userId.
+            var userId = ClaimHelpers.GetUserId(claimsPrincipal);
+            if (!userId.HasValue)
+            {
+                return false;
+            }
+
+            // Ensure userId links to a valid user.
+            var user = _userManager.GetUserById(userId.Value);
+            if (user == null)
+            {
+                return false;
+            }
+
+            // Ensure user is not disabled.
+            if (user.HasPermission(PermissionKind.IsDisabled))
+            {
+                return false;
+            }
+
+            var ip = RequestHelpers.NormalizeIp(_httpContextAccessor.HttpContext.Connection.RemoteIpAddress).ToString();
+            var isInLocalNetwork = _networkManager.IsInLocalNetwork(ip);
+            // User cannot access remotely and user is remote
+            if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !isInLocalNetwork)
+            {
+                return false;
+            }
+
+            if (localAccessOnly && !isInLocalNetwork)
+            {
+                return false;
+            }
+
+            // User attempting to access out of parental control hours.
+            if (!ignoreSchedule
+                && !user.HasPermission(PermissionKind.IsAdministrator)
+                && !user.IsParentalScheduleAllowed())
+            {
+                return false;
+            }
+
+            // User attempting to download without permission.
+            if (requiredDownloadPermission
+                && !user.HasPermission(PermissionKind.EnableContentDownloading))
+            {
+                return false;
+            }
+
+            return true;
+        }
+    }
+}

+ 13 - 2
Jellyfin.Api/Auth/CustomAuthenticationHandler.cs

@@ -1,3 +1,4 @@
+using System.Globalization;
 using System.Security.Authentication;
 using System.Security.Claims;
 using System.Text.Encodings.Web;
@@ -44,14 +45,24 @@ namespace Jellyfin.Api.Auth
                 var authorizationInfo = _authService.Authenticate(Request);
                 if (authorizationInfo == null)
                 {
-                    return Task.FromResult(AuthenticateResult.Fail("Invalid user"));
+                    return Task.FromResult(AuthenticateResult.NoResult());
+                    // TODO return when legacy API is removed.
+                    // Don't spam the log with "Invalid User"
+                    // return Task.FromResult(AuthenticateResult.Fail("Invalid user"));
                 }
 
                 var claims = new[]
                 {
                     new Claim(ClaimTypes.Name, authorizationInfo.User.Username),
-                    new Claim(ClaimTypes.Role, authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User)
+                    new Claim(ClaimTypes.Role, authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User),
+                    new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
+                    new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId),
+                    new Claim(InternalClaimTypes.Device, authorizationInfo.Device),
+                    new Claim(InternalClaimTypes.Client, authorizationInfo.Client),
+                    new Claim(InternalClaimTypes.Version, authorizationInfo.Version),
+                    new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
                 };
+
                 var identity = new ClaimsIdentity(claims, Scheme.Name);
                 var principal = new ClaimsPrincipal(identity);
                 var ticket = new AuthenticationTicket(principal, Scheme.Name);

+ 42 - 0
Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs

@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
+{
+    /// <summary>
+    /// Default authorization handler.
+    /// </summary>
+    public class DefaultAuthorizationHandler : BaseAuthorizationHandler<DefaultAuthorizationRequirement>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DefaultAuthorizationHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public DefaultAuthorizationHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement)
+        {
+            var validated = ValidateClaims(context.User);
+            if (!validated)
+            {
+                context.Fail();
+                return Task.CompletedTask;
+            }
+
+            context.Succeed(requirement);
+            return Task.CompletedTask;
+        }
+    }
+}

+ 11 - 0
Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs

@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
+{
+    /// <summary>
+    /// The default authorization requirement.
+    /// </summary>
+    public class DefaultAuthorizationRequirement : IAuthorizationRequirement
+    {
+    }
+}

+ 44 - 0
Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs

@@ -0,0 +1,44 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.DownloadPolicy
+{
+    /// <summary>
+    /// Download authorization handler.
+    /// </summary>
+    public class DownloadHandler : BaseAuthorizationHandler<DownloadRequirement>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DownloadHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public DownloadHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DownloadRequirement requirement)
+        {
+            var validated = ValidateClaims(context.User);
+            if (validated)
+            {
+                context.Succeed(requirement);
+            }
+            else
+            {
+                context.Fail();
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 11 - 0
Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs

@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.DownloadPolicy
+{
+    /// <summary>
+    /// The download permission requirement.
+    /// </summary>
+    public class DownloadRequirement : IAuthorizationRequirement
+    {
+    }
+}

+ 57 - 0
Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs

@@ -0,0 +1,57 @@
+using System.Threading.Tasks;
+using Jellyfin.Api.Auth.IgnoreParentalControlPolicy;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy
+{
+    /// <summary>
+    /// Ignore parental control schedule and allow before startup wizard has been completed.
+    /// </summary>
+    public class FirstTimeOrIgnoreParentalControlSetupHandler : BaseAuthorizationHandler<IgnoreParentalControlRequirement>
+    {
+        private readonly IConfigurationManager _configurationManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="FirstTimeOrIgnoreParentalControlSetupHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+        public FirstTimeOrIgnoreParentalControlSetupHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor,
+            IConfigurationManager configurationManager)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+            _configurationManager = configurationManager;
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement)
+        {
+            if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
+            {
+                context.Succeed(requirement);
+                return Task.CompletedTask;
+            }
+
+            var validated = ValidateClaims(context.User, ignoreSchedule: true);
+            if (validated)
+            {
+                context.Succeed(requirement);
+            }
+            else
+            {
+                context.Fail();
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 11 - 0
Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs

@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy
+{
+    /// <summary>
+    /// First time setup or ignore parental controls requirement.
+    /// </summary>
+    public class FirstTimeOrIgnoreParentalControlSetupRequirement : IAuthorizationRequirement
+    {
+    }
+}

+ 56 - 0
Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs

@@ -0,0 +1,56 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy
+{
+    /// <summary>
+    /// Authorization handler for requiring first time setup or default privileges.
+    /// </summary>
+    public class FirstTimeSetupOrDefaultHandler : BaseAuthorizationHandler<FirstTimeSetupOrDefaultRequirement>
+    {
+        private readonly IConfigurationManager _configurationManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="FirstTimeSetupOrDefaultHandler" /> class.
+        /// </summary>
+        /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public FirstTimeSetupOrDefaultHandler(
+            IConfigurationManager configurationManager,
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+            _configurationManager = configurationManager;
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrDefaultRequirement firstTimeSetupOrDefaultRequirement)
+        {
+            if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
+            {
+                context.Succeed(firstTimeSetupOrDefaultRequirement);
+                return Task.CompletedTask;
+            }
+
+            var validated = ValidateClaims(context.User);
+            if (validated)
+            {
+                context.Succeed(firstTimeSetupOrDefaultRequirement);
+            }
+            else
+            {
+                context.Fail();
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 11 - 0
Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs

@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy
+{
+    /// <summary>
+    /// The authorization requirement, requiring incomplete first time setup or default privileges, for the authorization handler.
+    /// </summary>
+    public class FirstTimeSetupOrDefaultRequirement : IAuthorizationRequirement
+    {
+    }
+}

+ 18 - 4
Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs

@@ -1,22 +1,33 @@
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
 using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
 
 namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy
 {
     /// <summary>
     /// Authorization handler for requiring first time setup or elevated privileges.
     /// </summary>
-    public class FirstTimeSetupOrElevatedHandler : AuthorizationHandler<FirstTimeSetupOrElevatedRequirement>
+    public class FirstTimeSetupOrElevatedHandler : BaseAuthorizationHandler<FirstTimeSetupOrElevatedRequirement>
     {
         private readonly IConfigurationManager _configurationManager;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="FirstTimeSetupOrElevatedHandler" /> class.
         /// </summary>
-        /// <param name="configurationManager">The jellyfin configuration manager.</param>
-        public FirstTimeSetupOrElevatedHandler(IConfigurationManager configurationManager)
+        /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public FirstTimeSetupOrElevatedHandler(
+            IConfigurationManager configurationManager,
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
         {
             _configurationManager = configurationManager;
         }
@@ -27,8 +38,11 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy
             if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
             {
                 context.Succeed(firstTimeSetupOrElevatedRequirement);
+                return Task.CompletedTask;
             }
-            else if (context.User.IsInRole(UserRoles.Administrator))
+
+            var validated = ValidateClaims(context.User);
+            if (validated && context.User.IsInRole(UserRoles.Administrator))
             {
                 context.Succeed(firstTimeSetupOrElevatedRequirement);
             }

+ 42 - 0
Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs

@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy
+{
+    /// <summary>
+    /// Escape schedule controls handler.
+    /// </summary>
+    public class IgnoreParentalControlHandler : BaseAuthorizationHandler<IgnoreParentalControlRequirement>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="IgnoreParentalControlHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public IgnoreParentalControlHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement)
+        {
+            var validated = ValidateClaims(context.User, ignoreSchedule: true);
+            if (!validated)
+            {
+                context.Fail();
+                return Task.CompletedTask;
+            }
+
+            context.Succeed(requirement);
+            return Task.CompletedTask;
+        }
+    }
+}

+ 11 - 0
Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs

@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy
+{
+    /// <summary>
+    /// Escape schedule controls requirement.
+    /// </summary>
+    public class IgnoreParentalControlRequirement : IAuthorizationRequirement
+    {
+    }
+}

+ 45 - 0
Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs

@@ -0,0 +1,45 @@
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy
+{
+    /// <summary>
+    /// Local access or require elevated privileges handler.
+    /// </summary>
+    public class LocalAccessOrRequiresElevationHandler : BaseAuthorizationHandler<LocalAccessOrRequiresElevationRequirement>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="LocalAccessOrRequiresElevationHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public LocalAccessOrRequiresElevationHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessOrRequiresElevationRequirement requirement)
+        {
+            var validated = ValidateClaims(context.User, localAccessOnly: true);
+            if (validated || context.User.IsInRole(UserRoles.Administrator))
+            {
+                context.Succeed(requirement);
+            }
+            else
+            {
+                context.Fail();
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 11 - 0
Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs

@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy
+{
+    /// <summary>
+    /// The local access or elevated privileges authorization requirement.
+    /// </summary>
+    public class LocalAccessOrRequiresElevationRequirement : IAuthorizationRequirement
+    {
+    }
+}

+ 44 - 0
Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs

@@ -0,0 +1,44 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.LocalAccessPolicy
+{
+    /// <summary>
+    /// Local access handler.
+    /// </summary>
+    public class LocalAccessHandler : BaseAuthorizationHandler<LocalAccessRequirement>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="LocalAccessHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public LocalAccessHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement)
+        {
+            var validated = ValidateClaims(context.User, localAccessOnly: true);
+            if (!validated)
+            {
+                context.Fail();
+            }
+            else
+            {
+                context.Succeed(requirement);
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 11 - 0
Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs

@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.LocalAccessPolicy
+{
+    /// <summary>
+    /// The local access authorization requirement.
+    /// </summary>
+    public class LocalAccessRequirement : IAuthorizationRequirement
+    {
+    }
+}

+ 24 - 2
Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs

@@ -1,21 +1,43 @@
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
 using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
 
 namespace Jellyfin.Api.Auth.RequiresElevationPolicy
 {
     /// <summary>
     /// Authorization handler for requiring elevated privileges.
     /// </summary>
-    public class RequiresElevationHandler : AuthorizationHandler<RequiresElevationRequirement>
+    public class RequiresElevationHandler : BaseAuthorizationHandler<RequiresElevationRequirement>
     {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="RequiresElevationHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public RequiresElevationHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+        }
+
         /// <inheritdoc />
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresElevationRequirement requirement)
         {
-            if (context.User.IsInRole(UserRoles.Administrator))
+            var validated = ValidateClaims(context.User);
+            if (validated && context.User.IsInRole(UserRoles.Administrator))
             {
                 context.Succeed(requirement);
             }
+            else
+            {
+                context.Fail();
+            }
 
             return Task.CompletedTask;
         }

+ 38 - 0
Jellyfin.Api/Constants/InternalClaimTypes.cs

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

+ 36 - 1
Jellyfin.Api/Constants/Policies.cs

@@ -5,14 +5,49 @@ namespace Jellyfin.Api.Constants
     /// </summary>
     public static class Policies
     {
+        /// <summary>
+        /// Policy name for default authorization.
+        /// </summary>
+        public const string DefaultAuthorization = "DefaultAuthorization";
+
         /// <summary>
         /// Policy name for requiring first time setup or elevated privileges.
         /// </summary>
-        public const string FirstTimeSetupOrElevated = "FirstTimeOrElevated";
+        public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated";
 
         /// <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 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 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 escaping schedule controls or requiring first time setup.
+        /// </summary>
+        public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl";
     }
 }

+ 57 - 0
Jellyfin.Api/Controllers/ActivityLogController.cs

@@ -0,0 +1,57 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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;
+        }
+
+        /// <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 ActionResult<QueryResult<ActivityLogEntry>> GetLogEntries(
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] DateTime? minDate,
+            [FromQuery] bool? hasUserId)
+        {
+            var filterFunc = new Func<IQueryable<ActivityLog>, IQueryable<ActivityLog>>(
+                entries => entries.Where(entry => entry.DateCreated >= minDate
+                                                  && (!hasUserId.HasValue || (hasUserId.Value
+                                                      ? entry.UserId != Guid.Empty
+                                                      : entry.UserId == Guid.Empty))));
+
+            return _activityManager.GetPagedResult(filterFunc, startIndex, limit);
+        }
+    }
+}

+ 134 - 0
Jellyfin.Api/Controllers/AlbumsController.cs

@@ -0,0 +1,134 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The albums controller.
+    /// </summary>
+    [Route("")]
+    public class AlbumsController : BaseJellyfinApiController
+    {
+        private readonly IUserManager _userManager;
+        private readonly ILibraryManager _libraryManager;
+        private readonly IDtoService _dtoService;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AlbumsController"/> 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 AlbumsController(
+            IUserManager userManager,
+            ILibraryManager libraryManager,
+            IDtoService dtoService)
+        {
+            _userManager = userManager;
+            _libraryManager = libraryManager;
+            _dtoService = dtoService;
+        }
+
+        /// <summary>
+        /// Finds albums similar to a given album.
+        /// </summary>
+        /// <param name="albumId">The album id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param>
+        /// <param name="limit">Optional. The maximum number of records to return.</param>
+        /// <response code="200">Similar albums returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar albums.</returns>
+        [HttpGet("Albums/{albumId}/Similar")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetSimilarAlbums(
+            [FromRoute] string albumId,
+            [FromQuery] Guid? userId,
+            [FromQuery] string? excludeArtistIds,
+            [FromQuery] int? limit)
+        {
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            return SimilarItemsHelper.GetSimilarItemsResult(
+                dtoOptions,
+                _userManager,
+                _libraryManager,
+                _dtoService,
+                userId,
+                albumId,
+                excludeArtistIds,
+                limit,
+                new[] { typeof(MusicAlbum) },
+                GetAlbumSimilarityScore);
+        }
+
+        /// <summary>
+        /// Finds artists similar to a given artist.
+        /// </summary>
+        /// <param name="artistId">The artist id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param>
+        /// <param name="limit">Optional. The maximum number of records to return.</param>
+        /// <response code="200">Similar artists returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar artists.</returns>
+        [HttpGet("Artists/{artistId}/Similar")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetSimilarArtists(
+            [FromRoute] string artistId,
+            [FromQuery] Guid? userId,
+            [FromQuery] string? excludeArtistIds,
+            [FromQuery] int? limit)
+        {
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            return SimilarItemsHelper.GetSimilarItemsResult(
+                dtoOptions,
+                _userManager,
+                _libraryManager,
+                _dtoService,
+                userId,
+                artistId,
+                excludeArtistIds,
+                limit,
+                new[] { typeof(MusicArtist) },
+                SimilarItemsHelper.GetSimiliarityScore);
+        }
+
+        /// <summary>
+        /// Gets a similairty score of two albums.
+        /// </summary>
+        /// <param name="item1">The first item.</param>
+        /// <param name="item1People">The item1 people.</param>
+        /// <param name="allPeople">All people.</param>
+        /// <param name="item2">The second item.</param>
+        /// <returns>System.Int32.</returns>
+        private int GetAlbumSimilarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> allPeople, BaseItem item2)
+        {
+            var points = SimilarItemsHelper.GetSimiliarityScore(item1, item1People, allPeople, item2);
+
+            var album1 = (MusicAlbum)item1;
+            var album2 = (MusicAlbum)item2;
+
+            var artists1 = album1
+                .GetAllArtists()
+                .DistinctNames()
+                .ToList();
+
+            var artists2 = new HashSet<string>(
+                album2.GetAllArtists().DistinctNames(),
+                StringComparer.OrdinalIgnoreCase);
+
+            return points + artists1.Where(artists2.Contains).Sum(i => 5);
+        }
+    }
+}

+ 97 - 0
Jellyfin.Api/Controllers/ApiKeyController.cs

@@ -0,0 +1,97 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Security;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Authentication controller.
+    /// </summary>
+    [Route("Auth")]
+    public class ApiKeyController : BaseJellyfinApiController
+    {
+        private readonly ISessionManager _sessionManager;
+        private readonly IServerApplicationHost _appHost;
+        private readonly IAuthenticationRepository _authRepo;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ApiKeyController"/> class.
+        /// </summary>
+        /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
+        /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
+        /// <param name="authRepo">Instance of <see cref="IAuthenticationRepository"/> interface.</param>
+        public ApiKeyController(
+            ISessionManager sessionManager,
+            IServerApplicationHost appHost,
+            IAuthenticationRepository authRepo)
+        {
+            _sessionManager = sessionManager;
+            _appHost = appHost;
+            _authRepo = authRepo;
+        }
+
+        /// <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 ActionResult<QueryResult<AuthenticationInfo>> GetKeys()
+        {
+            var result = _authRepo.Get(new AuthenticationInfoQuery
+            {
+                HasUser = false
+            });
+
+            return result;
+        }
+
+        /// <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 ActionResult CreateKey([FromQuery, Required] string? app)
+        {
+            _authRepo.Create(new AuthenticationInfo
+            {
+                AppName = app,
+                AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
+                DateCreated = DateTime.UtcNow,
+                DeviceId = _appHost.SystemId,
+                DeviceName = _appHost.FriendlyName,
+                AppVersion = _appHost.ApplicationVersionString
+            });
+            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 ActionResult RevokeKey([FromRoute, Required] string? key)
+        {
+            _sessionManager.RevokeToken(key);
+            return NoContent();
+        }
+    }
+}

+ 488 - 0
Jellyfin.Api/Controllers/ArtistsController.cs

@@ -0,0 +1,488 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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. 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 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. 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="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="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] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? genres,
+            [FromQuery] string? genreIds,
+            [FromQuery] string? officialRatings,
+            [FromQuery] string? tags,
+            [FromQuery] string? years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] string? person,
+            [FromQuery] string? personIds,
+            [FromQuery] string? personTypes,
+            [FromQuery] string? studios,
+            [FromQuery] string? studioIds,
+            [FromQuery] Guid? userId,
+            [FromQuery] string? nameStartsWithOrGreater,
+            [FromQuery] string? nameStartsWith,
+            [FromQuery] string? nameLessThan,
+            [FromQuery] bool? enableImages = true,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            User? user = null;
+            BaseItem parentItem;
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                user = _userManager.GetUserById(userId.Value);
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
+            }
+            else
+            {
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
+            }
+
+            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
+            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
+            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
+
+            var query = new InternalItemsQuery(user)
+            {
+                ExcludeItemTypes = excludeItemTypesArr,
+                IncludeItemTypes = includeItemTypesArr,
+                MediaTypes = mediaTypesArr,
+                StartIndex = startIndex,
+                Limit = limit,
+                IsFavorite = isFavorite,
+                NameLessThan = nameLessThan,
+                NameStartsWith = nameStartsWith,
+                NameStartsWithOrGreater = nameStartsWithOrGreater,
+                Tags = RequestHelpers.Split(tags, ',', true),
+                OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
+                Genres = RequestHelpers.Split(genres, ',', true),
+                GenreIds = RequestHelpers.GetGuids(genreIds),
+                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Person = person,
+                PersonIds = RequestHelpers.GetGuids(personIds),
+                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
+                Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+                MinCommunityRating = minCommunityRating,
+                DtoOptions = dtoOptions,
+                SearchTerm = searchTerm,
+                EnableTotalRecordCount = enableTotalRecordCount
+            };
+
+            if (!string.IsNullOrWhiteSpace(parentId))
+            {
+                if (parentItem is Folder)
+                {
+                    query.AncestorIds = new[] { new Guid(parentId) };
+                }
+                else
+                {
+                    query.ItemIds = new[] { new Guid(parentId) };
+                }
+            }
+
+            // Studios
+            if (!string.IsNullOrEmpty(studios))
+            {
+                query.StudioIds = studios.Split('|').Select(i =>
+                {
+                    try
+                    {
+                        return _libraryManager.GetStudio(i);
+                    }
+                    catch
+                    {
+                        return null;
+                    }
+                }).Where(i => i != null).Select(i => i!.Id).ToArray();
+            }
+
+            foreach (var filter in RequestHelpers.GetFilters(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.GetArtists(query);
+
+            var dtos = result.Items.Select(i =>
+            {
+                var (baseItem, itemCounts) = i;
+                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+                if (!string.IsNullOrWhiteSpace(includeItemTypes))
+                {
+                    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;
+            });
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos.ToArray(),
+                TotalRecordCount = result.TotalRecordCount
+            };
+        }
+
+        /// <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. 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 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. 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="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="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] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? genres,
+            [FromQuery] string? genreIds,
+            [FromQuery] string? officialRatings,
+            [FromQuery] string? tags,
+            [FromQuery] string? years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] string? person,
+            [FromQuery] string? personIds,
+            [FromQuery] string? personTypes,
+            [FromQuery] string? studios,
+            [FromQuery] string? studioIds,
+            [FromQuery] Guid? userId,
+            [FromQuery] string? nameStartsWithOrGreater,
+            [FromQuery] string? nameStartsWith,
+            [FromQuery] string? nameLessThan,
+            [FromQuery] bool? enableImages = true,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            User? user = null;
+            BaseItem parentItem;
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                user = _userManager.GetUserById(userId.Value);
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
+            }
+            else
+            {
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
+            }
+
+            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
+            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
+            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
+
+            var query = new InternalItemsQuery(user)
+            {
+                ExcludeItemTypes = excludeItemTypesArr,
+                IncludeItemTypes = includeItemTypesArr,
+                MediaTypes = mediaTypesArr,
+                StartIndex = startIndex,
+                Limit = limit,
+                IsFavorite = isFavorite,
+                NameLessThan = nameLessThan,
+                NameStartsWith = nameStartsWith,
+                NameStartsWithOrGreater = nameStartsWithOrGreater,
+                Tags = RequestHelpers.Split(tags, ',', true),
+                OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
+                Genres = RequestHelpers.Split(genres, ',', true),
+                GenreIds = RequestHelpers.GetGuids(genreIds),
+                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Person = person,
+                PersonIds = RequestHelpers.GetGuids(personIds),
+                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
+                Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+                MinCommunityRating = minCommunityRating,
+                DtoOptions = dtoOptions,
+                SearchTerm = searchTerm,
+                EnableTotalRecordCount = enableTotalRecordCount
+            };
+
+            if (!string.IsNullOrWhiteSpace(parentId))
+            {
+                if (parentItem is Folder)
+                {
+                    query.AncestorIds = new[] { new Guid(parentId) };
+                }
+                else
+                {
+                    query.ItemIds = new[] { new Guid(parentId) };
+                }
+            }
+
+            // Studios
+            if (!string.IsNullOrEmpty(studios))
+            {
+                query.StudioIds = studios.Split('|').Select(i =>
+                {
+                    try
+                    {
+                        return _libraryManager.GetStudio(i);
+                    }
+                    catch
+                    {
+                        return null;
+                    }
+                }).Where(i => i != null).Select(i => i!.Id).ToArray();
+            }
+
+            foreach (var filter in RequestHelpers.GetFilters(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 dtos = result.Items.Select(i =>
+            {
+                var (baseItem, itemCounts) = i;
+                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+                if (!string.IsNullOrWhiteSpace(includeItemTypes))
+                {
+                    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;
+            });
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos.ToArray(),
+                TotalRecordCount = result.TotalRecordCount
+            };
+        }
+
+        /// <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] string name, [FromQuery] Guid? userId)
+        {
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            var item = _libraryManager.GetArtist(name, dtoOptions);
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                var user = _userManager.GetUserById(userId.Value);
+
+                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+            }
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions);
+        }
+    }
+}

+ 353 - 0
Jellyfin.Api/Controllers/AudioController.cs

@@ -0,0 +1,353 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Dlna;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Net;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The audio controller.
+    /// </summary>
+    // TODO: In order to autheneticate this in the future, Dlna playback will require updating
+    public class AudioController : BaseJellyfinApiController
+    {
+        private readonly IDlnaManager _dlnaManager;
+        private readonly IAuthorizationContext _authContext;
+        private readonly IUserManager _userManager;
+        private readonly ILibraryManager _libraryManager;
+        private readonly IMediaSourceManager _mediaSourceManager;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+        private readonly IMediaEncoder _mediaEncoder;
+        private readonly IFileSystem _fileSystem;
+        private readonly ISubtitleEncoder _subtitleEncoder;
+        private readonly IConfiguration _configuration;
+        private readonly IDeviceManager _deviceManager;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+        private readonly IHttpClientFactory _httpClientFactory;
+
+        private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AudioController"/> class.
+        /// </summary>
+        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+        /// <param name="userManger">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> 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="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param>
+        /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param>
+        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+        /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param>
+        /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
+        public AudioController(
+            IDlnaManager dlnaManager,
+            IUserManager userManger,
+            IAuthorizationContext authorizationContext,
+            ILibraryManager libraryManager,
+            IMediaSourceManager mediaSourceManager,
+            IServerConfigurationManager serverConfigurationManager,
+            IMediaEncoder mediaEncoder,
+            IFileSystem fileSystem,
+            ISubtitleEncoder subtitleEncoder,
+            IConfiguration configuration,
+            IDeviceManager deviceManager,
+            TranscodingJobHelper transcodingJobHelper,
+            IHttpClientFactory httpClientFactory)
+        {
+            _dlnaManager = dlnaManager;
+            _authContext = authorizationContext;
+            _userManager = userManger;
+            _libraryManager = libraryManager;
+            _mediaSourceManager = mediaSourceManager;
+            _serverConfigurationManager = serverConfigurationManager;
+            _mediaEncoder = mediaEncoder;
+            _fileSystem = fileSystem;
+            _subtitleEncoder = subtitleEncoder;
+            _configuration = configuration;
+            _deviceManager = deviceManager;
+            _transcodingJobHelper = transcodingJobHelper;
+            _httpClientFactory = httpClientFactory;
+        }
+
+        /// <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 lenght.</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, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">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=stream}.{container?}", Name = "GetAudioStreamByContainer")]
+        [HttpGet("{itemId}/stream", Name = "GetAudioStream")]
+        [HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadAudioStreamByContainer")]
+        [HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult> GetAudioStream(
+            [FromRoute] Guid itemId,
+            [FromRoute] 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? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext? context,
+            [FromQuery] Dictionary<string, string>? streamOptions)
+        {
+            bool isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
+
+            var cancellationTokenSource = new CancellationTokenSource();
+
+            StreamingRequestDto streamingRequest = new StreamingRequestDto
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static ?? true,
+                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 ?? true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc ?? true,
+                DeInterlace = deInterlace ?? true,
+                RequireNonAnamorphic = requireNonAnamorphic ?? true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context ?? EncodingContext.Static,
+                StreamOptions = streamOptions
+            };
+
+            using var state = await StreamingHelpers.GetStreamingState(
+                    streamingRequest,
+                    Request,
+                    _authContext,
+                    _mediaSourceManager,
+                    _userManager,
+                    _libraryManager,
+                    _serverConfigurationManager,
+                    _mediaEncoder,
+                    _fileSystem,
+                    _subtitleEncoder,
+                    _configuration,
+                    _dlnaManager,
+                    _deviceManager,
+                    _transcodingJobHelper,
+                    _transcodingJobType,
+                    cancellationTokenSource.Token)
+                .ConfigureAwait(false);
+
+            if (@static.HasValue && @static.Value && state.DirectStreamProvider != null)
+            {
+                StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager);
+
+                await new ProgressiveFileCopier(state.DirectStreamProvider, null, _transcodingJobHelper, CancellationToken.None)
+                    {
+                        AllowEndOfFile = false
+                    }.WriteToAsync(Response.Body, CancellationToken.None)
+                    .ConfigureAwait(false);
+
+                // TODO (moved from MediaBrowser.Api): Don't hardcode contentType
+                return File(Response.Body, MimeTypes.GetMimeType("file.ts")!);
+            }
+
+            // Static remote stream
+            if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http)
+            {
+                StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager);
+
+                using var httpClient = _httpClientFactory.CreateClient();
+                return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, this, httpClient).ConfigureAwait(false);
+            }
+
+            if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File)
+            {
+                return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically");
+            }
+
+            var outputPath = state.OutputFilePath;
+            var outputPathExists = System.IO.File.Exists(outputPath);
+
+            var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
+            var isTranscodeCached = outputPathExists && transcodingJob != null;
+
+            StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, startTimeTicks, Request, _dlnaManager);
+
+            // Static stream
+            if (@static.HasValue && @static.Value)
+            {
+                var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
+
+                if (state.MediaSource.IsInfiniteStream)
+                {
+                    await new ProgressiveFileCopier(state.MediaPath, null, _transcodingJobHelper, CancellationToken.None)
+                        {
+                            AllowEndOfFile = false
+                        }.WriteToAsync(Response.Body, CancellationToken.None)
+                        .ConfigureAwait(false);
+
+                    return File(Response.Body, contentType);
+                }
+
+                return FileStreamResponseHelpers.GetStaticFileResult(
+                    state.MediaPath,
+                    contentType,
+                    isHeadRequest,
+                    this);
+            }
+
+            // Need to start ffmpeg (because media can't be returned directly)
+            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+            var encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
+            var ffmpegCommandLineArguments = encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath);
+            return await FileStreamResponseHelpers.GetTranscodedFile(
+                state,
+                isHeadRequest,
+                this,
+                _transcodingJobHelper,
+                ffmpegCommandLineArguments,
+                Request,
+                _transcodingJobType,
+                cancellationTokenSource).ConfigureAwait(false);
+        }
+    }
+}

+ 57 - 0
Jellyfin.Api/Controllers/BrandingController.cs

@@ -0,0 +1,57 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Branding;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Branding controller.
+    /// </summary>
+    public class BrandingController : BaseJellyfinApiController
+    {
+        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;
+        }
+
+        /// <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;
+        }
+    }
+}

+ 256 - 0
Jellyfin.Api/Controllers/ChannelsController.cs

@@ -0,0 +1,256 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Channels;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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)
+        {
+            _channelManager = channelManager;
+            _userManager = 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
+            {
+                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 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] string 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. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</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. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</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] Guid channelId,
+            [FromQuery] Guid? folderId,
+            [FromQuery] Guid? userId,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? sortOrder,
+            [FromQuery] string? filters,
+            [FromQuery] string? sortBy,
+            [FromQuery] string? fields)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            var query = new InternalItemsQuery(user)
+            {
+                Limit = limit,
+                StartIndex = startIndex,
+                ChannelIds = new[] { channelId },
+                ParentId = folderId ?? Guid.Empty,
+                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
+                DtoOptions = new DtoOptions()
+                    .AddItemFields(fields)
+            };
+
+            foreach (var filter in RequestHelpers.GetFilters(filters))
+            {
+                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;
+                }
+            }
+
+            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. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</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="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] string? filters,
+            [FromQuery] string? fields,
+            [FromQuery] string? channelIds)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            var query = new InternalItemsQuery(user)
+            {
+                Limit = limit,
+                StartIndex = startIndex,
+                ChannelIds = (channelIds ?? string.Empty)
+                    .Split(',')
+                    .Where(i => !string.IsNullOrWhiteSpace(i))
+                    .Select(i => new Guid(i))
+                    .ToArray(),
+                DtoOptions = new DtoOptions()
+                    .AddItemFields(fields)
+            };
+
+            foreach (var filter in RequestHelpers.GetFilters(filters))
+            {
+                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;
+                }
+            }
+
+            return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false);
+        }
+    }
+}

+ 111 - 0
Jellyfin.Api/Controllers/CollectionController.cs

@@ -0,0 +1,111 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Collections;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Collections;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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;
+        private readonly IAuthorizationContext _authContext;
+
+        /// <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>
+        /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
+        public CollectionController(
+            ICollectionManager collectionManager,
+            IDtoService dtoService,
+            IAuthorizationContext authContext)
+        {
+            _collectionManager = collectionManager;
+            _dtoService = dtoService;
+            _authContext = authContext;
+        }
+
+        /// <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 ActionResult<CollectionCreationResult> CreateCollection(
+            [FromQuery] string? name,
+            [FromQuery] string? ids,
+            [FromQuery] Guid? parentId,
+            [FromQuery] bool isLocked = false)
+        {
+            var userId = _authContext.GetAuthorizationInfo(Request).UserId;
+
+            var item = _collectionManager.CreateCollection(new CollectionCreationOptions
+            {
+                IsLocked = isLocked,
+                Name = name,
+                ParentId = parentId,
+                ItemIdList = RequestHelpers.Split(ids, ',', true),
+                UserIds = new[] { userId }
+            });
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            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="itemIds">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 ActionResult AddToCollection([FromRoute] Guid collectionId, [FromQuery, Required] string? itemIds)
+        {
+            _collectionManager.AddToCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Removes items from a collection.
+        /// </summary>
+        /// <param name="collectionId">The collection id.</param>
+        /// <param name="itemIds">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 ActionResult RemoveFromCollection([FromRoute] Guid collectionId, [FromQuery, Required] string? itemIds)
+        {
+            _collectionManager.RemoveFromCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
+            return NoContent();
+        }
+    }
+}

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

@@ -0,0 +1,126 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.ConfigurationDtos;
+using MediaBrowser.Common.Json;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Configuration;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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.GetOptions();
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ConfigurationController"/> class.
+        /// </summary>
+        /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+        public ConfigurationController(
+            IServerConfigurationManager configurationManager,
+            IMediaEncoder mediaEncoder)
+        {
+            _configurationManager = configurationManager;
+            _mediaEncoder = mediaEncoder;
+        }
+
+        /// <summary>
+        /// Gets application configuration.
+        /// </summary>
+        /// <response code="200">Application configuration returned.</response>
+        /// <returns>Application configuration.</returns>
+        [HttpGet("Configuration")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<ServerConfiguration> GetConfiguration()
+        {
+            return _configurationManager.Configuration;
+        }
+
+        /// <summary>
+        /// Updates application configuration.
+        /// </summary>
+        /// <param name="configuration">Configuration.</param>
+        /// <response code="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 a named configuration.
+        /// </summary>
+        /// <param name="key">Configuration key.</param>
+        /// <response code="200">Configuration returned.</response>
+        /// <returns>Configuration.</returns>
+        [HttpGet("Configuration/{key}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<object> GetNamedConfiguration([FromRoute] string? key)
+        {
+            return _configurationManager.GetConfiguration(key);
+        }
+
+        /// <summary>
+        /// Updates named configuration.
+        /// </summary>
+        /// <param name="key">Configuration key.</param>
+        /// <response code="204">Named configuration updated.</response>
+        /// <returns>Update status.</returns>
+        [HttpPost("Configuration/{key}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string? key)
+        {
+            var configurationType = _configurationManager.GetConfigurationType(key);
+            var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType, _serializerOptions).ConfigureAwait(false);
+            _configurationManager.SaveConfiguration(key, configuration);
+            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>
+        /// 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([FromForm, Required] MediaEncoderPathDto mediaEncoderPath)
+        {
+            _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
+            return NoContent();
+        }
+    }
+}

+ 271 - 0
Jellyfin.Api/Controllers/DashboardController.cs

@@ -0,0 +1,271 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Jellyfin.Api.Models;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Extensions;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Plugins;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The dashboard controller.
+    /// </summary>
+    [Route("")]
+    public class DashboardController : BaseJellyfinApiController
+    {
+        private readonly ILogger<DashboardController> _logger;
+        private readonly IServerApplicationHost _appHost;
+        private readonly IConfiguration _appConfig;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+        private readonly IResourceFileManager _resourceFileManager;
+
+        /// <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="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
+        /// <param name="appConfig">Instance of <see cref="IConfiguration"/> interface.</param>
+        /// <param name="resourceFileManager">Instance of <see cref="IResourceFileManager"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
+        public DashboardController(
+            ILogger<DashboardController> logger,
+            IServerApplicationHost appHost,
+            IConfiguration appConfig,
+            IResourceFileManager resourceFileManager,
+            IServerConfigurationManager serverConfigurationManager)
+        {
+            _logger = logger;
+            _appHost = appHost;
+            _appConfig = appConfig;
+            _resourceFileManager = resourceFileManager;
+            _serverConfigurationManager = serverConfigurationManager;
+        }
+
+        /// <summary>
+        /// Gets the path of the directory containing the static web interface content, or null if the server is not
+        /// hosting the web client.
+        /// </summary>
+        private string? WebClientUiPath => GetWebClientUiPath(_appConfig, _serverConfigurationManager);
+
+        /// <summary>
+        /// Gets the configuration pages.
+        /// </summary>
+        /// <param name="enableInMainMenu">Whether to enable in the main menu.</param>
+        /// <param name="pageType">The <see cref="ConfigurationPageInfo"/>.</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)]
+        public ActionResult<IEnumerable<ConfigurationPageInfo?>> GetConfigurationPages(
+            [FromQuery] bool? enableInMainMenu,
+            [FromQuery] ConfigurationPageType? pageType)
+        {
+            const string unavailableMessage = "The server is still loading. Please try again momentarily.";
+
+            var pages = _appHost.GetExports<IPluginConfigurationPage>().ToList();
+
+            if (pages == null)
+            {
+                return NotFound(unavailableMessage);
+            }
+
+            // Don't allow a failing plugin to fail them all
+            var configPages = pages.Select(p =>
+                {
+                    try
+                    {
+                        return new ConfigurationPageInfo(p);
+                    }
+                    catch (Exception ex)
+                    {
+                        _logger.LogError(ex, "Error getting plugin information from {Plugin}", p.GetType().Name);
+                        return null;
+                    }
+                })
+                .Where(i => i != null)
+                .ToList();
+
+            configPages.AddRange(_appHost.Plugins.SelectMany(GetConfigPages));
+
+            if (pageType.HasValue)
+            {
+                configPages = configPages.Where(p => p!.ConfigurationPageType == pageType).ToList();
+            }
+
+            if (enableInMainMenu.HasValue)
+            {
+                configPages = configPages.Where(p => p!.EnableInMainMenu == enableInMainMenu.Value).ToList();
+            }
+
+            return configPages;
+        }
+
+        /// <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)]
+        public ActionResult GetDashboardConfigurationPage([FromQuery] string? name)
+        {
+            IPlugin? plugin = null;
+            Stream? stream = null;
+
+            var isJs = false;
+            var isTemplate = false;
+
+            var page = _appHost.GetExports<IPluginConfigurationPage>().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
+            if (page != null)
+            {
+                plugin = page.Plugin;
+                stream = page.GetHtmlStream();
+            }
+
+            if (plugin == null)
+            {
+                var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase));
+                if (altPage != null)
+                {
+                    plugin = altPage.Item2;
+                    stream = plugin.GetType().Assembly.GetManifestResourceStream(altPage.Item1.EmbeddedResourcePath);
+
+                    isJs = string.Equals(Path.GetExtension(altPage.Item1.EmbeddedResourcePath), ".js", StringComparison.OrdinalIgnoreCase);
+                    isTemplate = altPage.Item1.EmbeddedResourcePath.EndsWith(".template.html", StringComparison.Ordinal);
+                }
+            }
+
+            if (plugin != null && stream != null)
+            {
+                if (isJs)
+                {
+                    return File(stream, MimeTypes.GetMimeType("page.js"));
+                }
+
+                if (isTemplate)
+                {
+                    return File(stream, MimeTypes.GetMimeType("page.html"));
+                }
+
+                return File(stream, MimeTypes.GetMimeType("page.html"));
+            }
+
+            return NotFound();
+        }
+
+        /// <summary>
+        /// Gets the robots.txt.
+        /// </summary>
+        /// <response code="200">Robots.txt returned.</response>
+        /// <returns>The robots.txt.</returns>
+        [HttpGet("robots.txt")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ApiExplorerSettings(IgnoreApi = true)]
+        public ActionResult GetRobotsTxt()
+        {
+            return GetWebClientResource("robots.txt");
+        }
+
+        /// <summary>
+        /// Gets a resource from the web client.
+        /// </summary>
+        /// <param name="resourceName">The resource name.</param>
+        /// <response code="200">Web client returned.</response>
+        /// <response code="404">Server does not host a web client.</response>
+        /// <returns>The resource.</returns>
+        [HttpGet("web/{*resourceName}")]
+        [ApiExplorerSettings(IgnoreApi = true)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult GetWebClientResource([FromRoute] string resourceName)
+        {
+            if (!_appConfig.HostWebClient() || WebClientUiPath == null)
+            {
+                return NotFound("Server does not host a web client.");
+            }
+
+            var path = resourceName;
+            var basePath = WebClientUiPath;
+
+            // Bounce them to the startup wizard if it hasn't been completed yet
+            if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted
+                && !Request.Path.Value.Contains("wizard", StringComparison.OrdinalIgnoreCase)
+                && Request.Path.Value.Contains("index", StringComparison.OrdinalIgnoreCase))
+            {
+                return Redirect("index.html?start=wizard#!/wizardstart.html");
+            }
+
+            var stream = new FileStream(_resourceFileManager.GetResourcePath(basePath, path), FileMode.Open, FileAccess.Read);
+            return File(stream, MimeTypes.GetMimeType(path));
+        }
+
+        /// <summary>
+        /// Gets the favicon.
+        /// </summary>
+        /// <response code="200">Favicon.ico returned.</response>
+        /// <returns>The favicon.</returns>
+        [HttpGet("favicon.ico")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ApiExplorerSettings(IgnoreApi = true)]
+        public ActionResult GetFavIcon()
+        {
+            return GetWebClientResource("favicon.ico");
+        }
+
+        /// <summary>
+        /// Gets the path of the directory containing the static web interface content.
+        /// </summary>
+        /// <param name="appConfig">The app configuration.</param>
+        /// <param name="serverConfigManager">The server configuration manager.</param>
+        /// <returns>The directory path, or null if the server is not hosting the web client.</returns>
+        public static string? GetWebClientUiPath(IConfiguration appConfig, IServerConfigurationManager serverConfigManager)
+        {
+            if (!appConfig.HostWebClient())
+            {
+                return null;
+            }
+
+            if (!string.IsNullOrEmpty(serverConfigManager.Configuration.DashboardSourcePath))
+            {
+                return serverConfigManager.Configuration.DashboardSourcePath;
+            }
+
+            return serverConfigManager.ApplicationPaths.WebPath;
+        }
+
+        private IEnumerable<ConfigurationPageInfo> GetConfigPages(IPlugin plugin)
+        {
+            return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin, i.Item1));
+        }
+
+        private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(IPlugin plugin)
+        {
+            if (!(plugin is IHasWebPages hasWebPages))
+            {
+                return new List<Tuple<PluginPageInfo, IPlugin>>();
+            }
+
+            return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin));
+        }
+
+        private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages()
+        {
+            return _appHost.Plugins.SelectMany(GetPluginPages);
+        }
+    }
+}

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

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

+ 176 - 0
Jellyfin.Api/Controllers/DisplayPreferencesController.cs

@@ -0,0 +1,176 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller;
+using MediaBrowser.Model.Entities;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Display Preferences Controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class DisplayPreferencesController : BaseJellyfinApiController
+    {
+        private readonly IDisplayPreferencesManager _displayPreferencesManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class.
+        /// </summary>
+        /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param>
+        public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager)
+        {
+            _displayPreferencesManager = displayPreferencesManager;
+        }
+
+        /// <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] string? displayPreferencesId,
+            [FromQuery] [Required] Guid userId,
+            [FromQuery] [Required] string? client)
+        {
+            var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client);
+            var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client);
+
+            var dto = new DisplayPreferencesDto
+            {
+                Client = displayPreferences.Client,
+                Id = displayPreferences.UserId.ToString(),
+                ViewType = itemPreferences.ViewType.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();
+            }
+
+            foreach (var itemDisplayPreferences in _displayPreferencesManager.ListItemDisplayPreferences(displayPreferences.UserId, displayPreferences.Client))
+            {
+                dto.CustomPrefs["landing-" + itemDisplayPreferences.ItemId] = itemDisplayPreferences.ViewType.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;
+
+            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] string? displayPreferencesId,
+            [FromQuery, Required] Guid userId,
+            [FromQuery, Required] string? client,
+            [FromBody, Required] DisplayPreferencesDto displayPreferences)
+        {
+            HomeSectionType[] defaults =
+            {
+                HomeSectionType.SmallLibraryTiles,
+                HomeSectionType.Resume,
+                HomeSectionType.ResumeAudio,
+                HomeSectionType.LiveTv,
+                HomeSectionType.NextUp,
+                HomeSectionType.LatestMedia, HomeSectionType.None,
+            };
+
+            var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client);
+            existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null;
+            existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop;
+            existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar;
+
+            existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection;
+            existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion)
+                ? Enum.Parse<ChromecastVersion>(chromecastVersion, true)
+                : ChromecastVersion.Stable;
+            existingDisplayPreferences.EnableNextVideoInfoOverlay = displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay)
+                ? bool.Parse(enableNextVideoInfoOverlay)
+                : true;
+            existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength)
+                ? int.Parse(skipBackLength, CultureInfo.InvariantCulture)
+                : 10000;
+            existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength)
+                ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture)
+                : 30000;
+            existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme)
+                ? theme
+                : string.Empty;
+            existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home)
+                ? home
+                : string.Empty;
+            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));
+                if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type))
+                {
+                    type = order < 7 ? defaults[order] : HomeSectionType.None;
+                }
+
+                existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type });
+            }
+
+            foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
+            {
+                var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Parse(key.Substring("landing-".Length)), existingDisplayPreferences.Client);
+                itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType);
+                _displayPreferencesManager.SaveChanges(itemPreferences);
+            }
+
+            var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Empty, existingDisplayPreferences.Client);
+            itemPrefs.SortBy = displayPreferences.SortBy;
+            itemPrefs.SortOrder = displayPreferences.SortOrder;
+            itemPrefs.RememberIndexing = displayPreferences.RememberIndexing;
+            itemPrefs.RememberSorting = displayPreferences.RememberSorting;
+
+            if (Enum.TryParse<ViewType>(displayPreferences.ViewType, true, out var viewType))
+            {
+                itemPrefs.ViewType = viewType;
+            }
+
+            _displayPreferencesManager.SaveChanges(existingDisplayPreferences);
+            _displayPreferencesManager.SaveChanges(itemPrefs);
+
+            return NoContent();
+        }
+    }
+}

+ 132 - 0
Jellyfin.Api/Controllers/DlnaController.cs

@@ -0,0 +1,132 @@
+using System.Collections.Generic;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller.Dlna;
+using MediaBrowser.Model.Dlna;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Dlna Controller.
+    /// </summary>
+    [Authorize(Policy = Policies.RequiresElevation)]
+    public class DlnaController : BaseJellyfinApiController
+    {
+        private readonly IDlnaManager _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>
+        /// 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 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] string profileId)
+        {
+            var profile = _dlnaManager.GetProfile(profileId);
+            if (profile == null)
+            {
+                return NotFound();
+            }
+
+            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] string profileId)
+        {
+            var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
+            if (existingDeviceProfile == null)
+            {
+                return NotFound();
+            }
+
+            _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>
+        /// 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] string profileId, [FromBody] DeviceProfile deviceProfile)
+        {
+            var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
+            if (existingDeviceProfile == null)
+            {
+                return NotFound();
+            }
+
+            _dlnaManager.UpdateProfile(deviceProfile);
+            return NoContent();
+        }
+    }
+}

+ 257 - 0
Jellyfin.Api/Controllers/DlnaServerController.cs

@@ -0,0 +1,257 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Threading.Tasks;
+using Emby.Dlna;
+using Emby.Dlna.Main;
+using Jellyfin.Api.Attributes;
+using MediaBrowser.Controller.Dlna;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Dlna Server Controller.
+    /// </summary>
+    [Route("Dlna")]
+    public class DlnaServerController : BaseJellyfinApiController
+    {
+        private const string XMLContentType = "text/xml; charset=UTF-8";
+
+        private readonly IDlnaManager _dlnaManager;
+        private readonly IContentDirectory _contentDirectory;
+        private readonly IConnectionManager _connectionManager;
+        private readonly IMediaReceiverRegistrar _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>
+        /// <returns>An <see cref="OkResult"/> containing the description xml.</returns>
+        [HttpGet("{serverId}/description")]
+        [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")]
+        [Produces(XMLContentType)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult GetDescriptionXml([FromRoute] 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>
+        /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns>
+        [HttpGet("{serverId}/ContentDirectory/ContentDirectory")]
+        [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_2")]
+        [Produces(XMLContentType)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        public ActionResult GetContentDirectory([FromRoute] string serverId)
+        {
+            return Ok(_contentDirectory.GetServiceXml());
+        }
+
+        /// <summary>
+        /// Gets Dlna media receiver registrar xml.
+        /// </summary>
+        /// <param name="serverId">Server UUID.</param>
+        /// <returns>Dlna media receiver registrar xml.</returns>
+        [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar")]
+        [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_2")]
+        [Produces(XMLContentType)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        public ActionResult GetMediaReceiverRegistrar([FromRoute] string serverId)
+        {
+            return Ok(_mediaReceiverRegistrar.GetServiceXml());
+        }
+
+        /// <summary>
+        /// Gets Dlna media receiver registrar xml.
+        /// </summary>
+        /// <param name="serverId">Server UUID.</param>
+        /// <returns>Dlna media receiver registrar xml.</returns>
+        [HttpGet("{serverId}/ConnectionManager/ConnectionManager")]
+        [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_2")]
+        [Produces(XMLContentType)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        public ActionResult GetConnectionManager([FromRoute] string serverId)
+        {
+            return Ok(_connectionManager.GetServiceXml());
+        }
+
+        /// <summary>
+        /// Process a content directory control request.
+        /// </summary>
+        /// <param name="serverId">Server UUID.</param>
+        /// <returns>Control response.</returns>
+        [HttpPost("{serverId}/ContentDirectory/Control")]
+        public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute] 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>
+        /// <returns>Control response.</returns>
+        [HttpPost("{serverId}/ConnectionManager/Control")]
+        public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute] 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>
+        /// <returns>Control response.</returns>
+        [HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
+        public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute] string serverId)
+        {
+            return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Processes an event subscription request.
+        /// </summary>
+        /// <param name="serverId">Server UUID.</param>
+        /// <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")]
+        public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
+        {
+            return ProcessEventRequest(_mediaReceiverRegistrar);
+        }
+
+        /// <summary>
+        /// Processes an event subscription request.
+        /// </summary>
+        /// <param name="serverId">Server UUID.</param>
+        /// <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")]
+        public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
+        {
+            return ProcessEventRequest(_contentDirectory);
+        }
+
+        /// <summary>
+        /// Processes an event subscription request.
+        /// </summary>
+        /// <param name="serverId">Server UUID.</param>
+        /// <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")]
+        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>
+        /// <returns>Icon stream.</returns>
+        [HttpGet("{serverId}/icons/{fileName}")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        public ActionResult GetIconId([FromRoute] string serverId, [FromRoute] string fileName)
+        {
+            return GetIconInternal(fileName);
+        }
+
+        /// <summary>
+        /// Gets a server icon.
+        /// </summary>
+        /// <param name="fileName">The icon filename.</param>
+        /// <returns>Icon stream.</returns>
+        [HttpGet("icons/{fileName}")]
+        public ActionResult GetIcon([FromRoute] string fileName)
+        {
+            return GetIconInternal(fileName);
+        }
+
+        private ActionResult GetIconInternal(string fileName)
+        {
+            var icon = _dlnaManager.GetIcon(fileName);
+            if (icon == null)
+            {
+                return NotFound();
+            }
+
+            var contentType = "image/" + Path.GetExtension(fileName)
+                .TrimStart('.')
+                .ToLowerInvariant();
+
+            return File(icon.Stream, contentType);
+        }
+
+        private string GetAbsoluteUri()
+        {
+            return $"{Request.Scheme}://{Request.Host}{Request.Path}";
+        }
+
+        private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service)
+        {
+            return service.ProcessControlRequestAsync(new ControlRequest
+            {
+                Headers = Request.Headers,
+                InputXml = requestStream,
+                TargetServerUuId = id,
+                RequestedUrl = GetAbsoluteUri()
+            });
+        }
+
+        private EventSubscriptionResponse ProcessEventRequest(IEventManager eventManager)
+        {
+            var subscriptionId = Request.Headers["SID"];
+            if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase))
+            {
+                var notificationType = Request.Headers["NT"];
+                var callback = Request.Headers["CALLBACK"];
+                var timeoutString = Request.Headers["TIMEOUT"];
+
+                if (string.IsNullOrEmpty(notificationType))
+                {
+                    return eventManager.RenewEventSubscription(
+                        subscriptionId,
+                        notificationType,
+                        timeoutString,
+                        callback);
+                }
+
+                return eventManager.CreateEventSubscription(notificationType, timeoutString, callback);
+            }
+
+            return eventManager.CancelEventSubscription(subscriptionId);
+        }
+    }
+}

+ 2216 - 0
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -0,0 +1,2216 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.PlaybackDtos;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Dlna;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Dynamic hls controller.
+    /// </summary>
+    [Route("")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class DynamicHlsController : BaseJellyfinApiController
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly IUserManager _userManager;
+        private readonly IDlnaManager _dlnaManager;
+        private readonly IAuthorizationContext _authContext;
+        private readonly IMediaSourceManager _mediaSourceManager;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+        private readonly IMediaEncoder _mediaEncoder;
+        private readonly IFileSystem _fileSystem;
+        private readonly ISubtitleEncoder _subtitleEncoder;
+        private readonly IConfiguration _configuration;
+        private readonly IDeviceManager _deviceManager;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+        private readonly INetworkManager _networkManager;
+        private readonly ILogger<DynamicHlsController> _logger;
+        private readonly EncodingHelper _encodingHelper;
+
+        private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
+
+        /// <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="authContext">Instance of the <see cref="IAuthorizationContext"/> 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="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param>
+        /// <param name="configuration">Instance of the <see cref="IConfiguration"/> 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="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param>
+        public DynamicHlsController(
+            ILibraryManager libraryManager,
+            IUserManager userManager,
+            IDlnaManager dlnaManager,
+            IAuthorizationContext authContext,
+            IMediaSourceManager mediaSourceManager,
+            IServerConfigurationManager serverConfigurationManager,
+            IMediaEncoder mediaEncoder,
+            IFileSystem fileSystem,
+            ISubtitleEncoder subtitleEncoder,
+            IConfiguration configuration,
+            IDeviceManager deviceManager,
+            TranscodingJobHelper transcodingJobHelper,
+            INetworkManager networkManager,
+            ILogger<DynamicHlsController> logger)
+        {
+            _libraryManager = libraryManager;
+            _userManager = userManager;
+            _dlnaManager = dlnaManager;
+            _authContext = authContext;
+            _mediaSourceManager = mediaSourceManager;
+            _serverConfigurationManager = serverConfigurationManager;
+            _mediaEncoder = mediaEncoder;
+            _fileSystem = fileSystem;
+            _subtitleEncoder = subtitleEncoder;
+            _configuration = configuration;
+            _deviceManager = deviceManager;
+            _transcodingJobHelper = transcodingJobHelper;
+            _networkManager = networkManager;
+            _logger = logger;
+
+            _encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
+        }
+
+        /// <summary>
+        /// Gets a video hls playlist 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 lenght.</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, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">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)]
+        public async Task<ActionResult> GetMasterHlsVideoPlaylist(
+            [FromRoute] Guid itemId,
+            [FromRoute] 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, 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? 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? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext context,
+            [FromQuery] Dictionary<string, string> streamOptions,
+            [FromQuery] bool enableAdaptiveBitrateStreaming = true)
+        {
+            var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
+            var cancellationTokenSource = new CancellationTokenSource();
+            var streamingRequest = new HlsVideoRequestDto
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static ?? true,
+                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 ?? true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc ?? true,
+                DeInterlace = deInterlace ?? true,
+                RequireNonAnamorphic = requireNonAnamorphic ?? true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context,
+                StreamOptions = streamOptions,
+                EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
+            };
+
+            return await GetMasterPlaylistInternal(streamingRequest, isHeadRequest, enableAdaptiveBitrateStreaming, cancellationTokenSource)
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Gets an audio hls playlist 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 lenght.</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, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">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)]
+        public async Task<ActionResult> GetMasterHlsAudioPlaylist(
+            [FromRoute] Guid itemId,
+            [FromRoute] 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, 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? 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? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext context,
+            [FromQuery] Dictionary<string, string> streamOptions,
+            [FromQuery] bool enableAdaptiveBitrateStreaming = true)
+        {
+            var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
+            var cancellationTokenSource = new CancellationTokenSource();
+            var streamingRequest = new HlsAudioRequestDto
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static ?? true,
+                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 ?? true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc ?? true,
+                DeInterlace = deInterlace ?? true,
+                RequireNonAnamorphic = requireNonAnamorphic ?? true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context,
+                StreamOptions = streamOptions,
+                EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
+            };
+
+            return await GetMasterPlaylistInternal(streamingRequest, isHeadRequest, enableAdaptiveBitrateStreaming, cancellationTokenSource)
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Gets a video stream using HTTP live streaming.
+        /// </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 lenght.</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, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">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)]
+        public async Task<ActionResult> GetVariantHlsVideoPlaylist(
+            [FromRoute] Guid itemId,
+            [FromRoute] 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? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext context,
+            [FromQuery] Dictionary<string, string> streamOptions)
+        {
+            var cancellationTokenSource = new CancellationTokenSource();
+            var streamingRequest = new VideoRequestDto
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static ?? true,
+                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 ?? true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc ?? true,
+                DeInterlace = deInterlace ?? true,
+                RequireNonAnamorphic = requireNonAnamorphic ?? true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context,
+                StreamOptions = streamOptions
+            };
+
+            return await GetVariantPlaylistInternal(streamingRequest, "main", cancellationTokenSource)
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Gets an audio stream using HTTP live streaming.
+        /// </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 lenght.</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, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">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)]
+        public async Task<ActionResult> GetVariantHlsAudioPlaylist(
+            [FromRoute] Guid itemId,
+            [FromRoute] 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? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext context,
+            [FromQuery] Dictionary<string, string> streamOptions)
+        {
+            var cancellationTokenSource = new CancellationTokenSource();
+            var streamingRequest = new StreamingRequestDto
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static ?? true,
+                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 ?? true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc ?? true,
+                DeInterlace = deInterlace ?? true,
+                RequireNonAnamorphic = requireNonAnamorphic ?? true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context,
+                StreamOptions = streamOptions
+            };
+
+            return await GetVariantPlaylistInternal(streamingRequest, "main", 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="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 lenght.</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, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">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)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
+        public async Task<ActionResult> GetHlsVideoSegment(
+            [FromRoute] Guid itemId,
+            [FromRoute] string playlistId,
+            [FromRoute] int segmentId,
+            [FromRoute] 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? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext context,
+            [FromQuery] Dictionary<string, string> streamOptions)
+        {
+            var streamingRequest = new VideoRequestDto
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static ?? true,
+                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 ?? true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc ?? true,
+                DeInterlace = deInterlace ?? true,
+                RequireNonAnamorphic = requireNonAnamorphic ?? true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context,
+                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="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 lenght.</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, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">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)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
+        public async Task<ActionResult> GetHlsAudioSegment(
+            [FromRoute] Guid itemId,
+            [FromRoute] string playlistId,
+            [FromRoute] int segmentId,
+            [FromRoute] 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? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext context,
+            [FromQuery] Dictionary<string, string> streamOptions)
+        {
+            var streamingRequest = new StreamingRequestDto
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static ?? true,
+                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 ?? true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc ?? true,
+                DeInterlace = deInterlace ?? true,
+                RequireNonAnamorphic = requireNonAnamorphic ?? true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context,
+                StreamOptions = streamOptions
+            };
+
+            return await GetDynamicSegment(streamingRequest, segmentId)
+                .ConfigureAwait(false);
+        }
+
+        private async Task<ActionResult> GetMasterPlaylistInternal(
+            StreamingRequestDto streamingRequest,
+            bool isHeadRequest,
+            bool enableAdaptiveBitrateStreaming,
+            CancellationTokenSource cancellationTokenSource)
+        {
+            using var state = await StreamingHelpers.GetStreamingState(
+                    streamingRequest,
+                    Request,
+                    _authContext,
+                    _mediaSourceManager,
+                    _userManager,
+                    _libraryManager,
+                    _serverConfigurationManager,
+                    _mediaEncoder,
+                    _fileSystem,
+                    _subtitleEncoder,
+                    _configuration,
+                    _dlnaManager,
+                    _deviceManager,
+                    _transcodingJobHelper,
+                    _transcodingJobType,
+                    cancellationTokenSource.Token)
+                .ConfigureAwait(false);
+
+            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 builder = new StringBuilder();
+
+            builder.AppendLine("#EXTM3U");
+
+            var isLiveStream = state.IsSegmentedLiveStream;
+
+            var queryString = Request.QueryString.ToString();
+
+            // from universal audio service
+            if (queryString.IndexOf("SegmentContainer", StringComparison.OrdinalIgnoreCase) == -1 && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer))
+            {
+                queryString += "&SegmentContainer=" + state.Request.SegmentContainer;
+            }
+
+            // from universal audio service
+            if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) && queryString.IndexOf("TranscodeReasons=", StringComparison.OrdinalIgnoreCase) == -1)
+            {
+                queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
+            }
+
+            // Main stream
+            var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
+
+            playlistUrl += queryString;
+
+            var subtitleStreams = state.MediaSource
+                .MediaStreams
+                .Where(i => i.IsTextSubtitleStream)
+                .ToList();
+
+            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 != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
+            {
+                subtitleGroup = null;
+            }
+
+            if (!string.IsNullOrWhiteSpace(subtitleGroup))
+            {
+                AddSubtitles(state, subtitleStreams, builder);
+            }
+
+            AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
+
+            if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming))
+            {
+                var requestedVideoBitrate = state.VideoRequest == null ? 0 : state.VideoRequest.VideoBitRate ?? 0;
+
+                // By default, vary by just 200k
+                var variation = GetBitrateVariation(totalBitrate);
+
+                var newBitrate = totalBitrate - variation;
+                var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+                AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
+
+                variation *= 2;
+                newBitrate = totalBitrate - variation;
+                variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+                AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
+            }
+
+            return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
+        }
+
+        private async Task<ActionResult> GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, string name, CancellationTokenSource cancellationTokenSource)
+        {
+            using var state = await StreamingHelpers.GetStreamingState(
+                    streamingRequest,
+                    Request,
+                    _authContext,
+                    _mediaSourceManager,
+                    _userManager,
+                    _libraryManager,
+                    _serverConfigurationManager,
+                    _mediaEncoder,
+                    _fileSystem,
+                    _subtitleEncoder,
+                    _configuration,
+                    _dlnaManager,
+                    _deviceManager,
+                    _transcodingJobHelper,
+                    _transcodingJobType,
+                    cancellationTokenSource.Token)
+                .ConfigureAwait(false);
+
+            Response.Headers.Add(HeaderNames.Expires, "0");
+
+            var segmentLengths = GetSegmentLengths(state);
+
+            var builder = new StringBuilder();
+
+            builder.AppendLine("#EXTM3U");
+            builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
+            builder.AppendLine("#EXT-X-VERSION:3");
+            builder.AppendLine("#EXT-X-TARGETDURATION:" + Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength).ToString(CultureInfo.InvariantCulture));
+            builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0");
+
+            var queryString = Request.QueryString;
+            var index = 0;
+
+            var segmentExtension = GetSegmentFileExtension(streamingRequest.SegmentContainer);
+
+            foreach (var length in segmentLengths)
+            {
+                builder.AppendLine("#EXTINF:" + length.ToString("0.0000", CultureInfo.InvariantCulture) + ", nodesc");
+                builder.AppendLine(
+                    string.Format(
+                        CultureInfo.InvariantCulture,
+                        "hls1/{0}/{1}{2}{3}",
+                        name,
+                        index.ToString(CultureInfo.InvariantCulture),
+                        segmentExtension,
+                        queryString));
+
+                index++;
+            }
+
+            builder.AppendLine("#EXT-X-ENDLIST");
+            return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
+        }
+
+        private async Task<ActionResult> GetDynamicSegment(StreamingRequestDto streamingRequest, int segmentId)
+        {
+            if ((streamingRequest.StartTimeTicks ?? 0) > 0)
+            {
+                throw new ArgumentException("StartTimeTicks is not allowed.");
+            }
+
+            var cancellationTokenSource = new CancellationTokenSource();
+            var cancellationToken = cancellationTokenSource.Token;
+
+            using var state = await StreamingHelpers.GetStreamingState(
+                    streamingRequest,
+                    Request,
+                    _authContext,
+                    _mediaSourceManager,
+                    _userManager,
+                    _libraryManager,
+                    _serverConfigurationManager,
+                    _mediaEncoder,
+                    _fileSystem,
+                    _subtitleEncoder,
+                    _configuration,
+                    _dlnaManager,
+                    _deviceManager,
+                    _transcodingJobHelper,
+                    _transcodingJobType,
+                    cancellationTokenSource.Token)
+                .ConfigureAwait(false);
+
+            var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
+
+            var segmentPath = GetSegmentPath(state, playlistPath, segmentId);
+
+            var segmentExtension = 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);
+            }
+
+            var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
+            await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
+            var released = false;
+            var startTranscoding = false;
+
+            try
+            {
+                if (System.IO.File.Exists(segmentPath))
+                {
+                    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);
+                }
+                else
+                {
+                    var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
+                    var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength;
+
+                    if (currentTranscodingIndex == 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;
+                    }
+
+                    if (startTranscoding)
+                    {
+                        // If the playlist doesn't already exist, startup ffmpeg
+                        try
+                        {
+                            await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false)
+                                .ConfigureAwait(false);
+
+                            if (currentTranscodingIndex.HasValue)
+                            {
+                                DeleteLastFile(playlistPath, segmentExtension, 0);
+                            }
+
+                            streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId);
+
+                            state.WaitForPath = segmentPath;
+                            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+                            job = await _transcodingJobHelper.StartFfMpeg(
+                                state,
+                                playlistPath,
+                                GetCommandLineArguments(playlistPath, encodingOptions, state, true, segmentId),
+                                Request,
+                                _transcodingJobType,
+                                cancellationTokenSource).ConfigureAwait(false);
+                        }
+                        catch
+                        {
+                            state.Dispose();
+                            throw;
+                        }
+
+                        // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false);
+                    }
+                    else
+                    {
+                        job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType);
+                        if (job?.TranscodingThrottler != null)
+                        {
+                            await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
+                        }
+                    }
+                }
+            }
+            finally
+            {
+                if (!released)
+                {
+                    transcodingLock.Release();
+                }
+            }
+
+            _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 void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder)
+        {
+            var selectedIndex = state.SubtitleStream == 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),
+                    ClaimHelpers.GetToken(Request.HttpContext.User));
+
+                var line = string.Format(
+                    CultureInfo.InvariantCulture,
+                    Format,
+                    name,
+                    isDefault ? "YES" : "NO",
+                    isForced ? "YES" : "NO",
+                    url,
+                    stream.Language ?? "Unknown");
+
+                builder.AppendLine(line);
+            }
+        }
+
+        private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
+        {
+            builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
+                .Append(bitrate.ToString(CultureInfo.InvariantCulture))
+                .Append(",AVERAGE-BANDWIDTH=")
+                .Append(bitrate.ToString(CultureInfo.InvariantCulture));
+
+            AppendPlaylistCodecsField(builder, state);
+
+            AppendPlaylistResolutionField(builder, state);
+
+            AppendPlaylistFramerateField(builder, state);
+
+            if (!string.IsNullOrWhiteSpace(subtitleGroup))
+            {
+                builder.Append(",SUBTITLES=\"")
+                    .Append(subtitleGroup)
+                    .Append('"');
+            }
+
+            builder.Append(Environment.NewLine);
+            builder.AppendLine(url);
+        }
+
+        /// <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);
+            }
+
+            // Audio
+            string audioCodecs = string.Empty;
+            if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec))
+            {
+                audioCodecs = GetPlaylistAudioCodecs(state);
+            }
+
+            StringBuilder codecs = new StringBuilder();
+
+            codecs.Append(videoCodecs)
+                .Append(',')
+                .Append(audioCodecs);
+
+            if (codecs.Length > 1)
+            {
+                builder.Append(",CODECS=\"")
+                    .Append(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)
+        {
+            if (state.OutputWidth.HasValue && state.OutputHeight.HasValue)
+            {
+                builder.Append(",RESOLUTION=")
+                    .Append(state.OutputWidth.GetValueOrDefault())
+                    .Append('x')
+                    .Append(state.OutputHeight.GetValueOrDefault());
+            }
+        }
+
+        /// <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 != null)
+            {
+                framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3);
+            }
+
+            if (framerate.HasValue)
+            {
+                builder.Append(",FRAME-RATE=")
+                    .Append(framerate.Value);
+            }
+        }
+
+        private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming)
+        {
+            // Within the local network this will likely do more harm than good.
+            var ip = RequestHelpers.NormalizeIp(Request.HttpContext.Connection.RemoteIpAddress).ToString();
+            if (_networkManager.IsInLocalNetwork(ip))
+            {
+                return false;
+            }
+
+            if (!enableAdaptiveBitrateStreaming)
+            {
+                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.OutputVideoCodec))
+            {
+                return false;
+            }
+
+            if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec))
+            {
+                return false;
+            }
+
+            if (!state.IsOutputVideo)
+            {
+                return false;
+            }
+
+            // Having problems in android
+            return false;
+            // return state.VideoRequest.VideoBitRate.HasValue;
+        }
+
+        /// <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;
+            if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+                && state.VideoStream.Level.HasValue)
+            {
+                levelString = state.VideoStream?.Level.ToString();
+            }
+            else
+            {
+                levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec);
+            }
+
+            if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
+            {
+                return parsedLevel;
+            }
+
+            return null;
+        }
+
+        /// <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, "mp3", StringComparison.OrdinalIgnoreCase))
+            {
+                return HlsCodecStringHelpers.GetMP3String();
+            }
+
+            if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
+            {
+                return HlsCodecStringHelpers.GetAC3String();
+            }
+
+            if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
+            {
+                return HlsCodecStringHelpers.GetEAC3String();
+            }
+
+            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;
+            }
+
+            if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase))
+            {
+                string profile = state.GetRequestedProfiles("h264").FirstOrDefault();
+                return HlsCodecStringHelpers.GetH264String(profile, level);
+            }
+
+            if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+            {
+                string profile = state.GetRequestedProfiles("h265").FirstOrDefault();
+
+                return HlsCodecStringHelpers.GetH265String(profile, level);
+            }
+
+            return string.Empty;
+        }
+
+        private int GetBitrateVariation(int bitrate)
+        {
+            // 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;
+        }
+
+        private string ReplaceBitrate(string url, int oldValue, int newValue)
+        {
+            return url.Replace(
+                "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
+                "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
+                StringComparison.OrdinalIgnoreCase);
+        }
+
+        private double[] GetSegmentLengths(StreamState state)
+        {
+            var result = new List<double>();
+
+            var ticks = state.RunTimeTicks ?? 0;
+
+            var segmentLengthTicks = TimeSpan.FromSeconds(state.SegmentLength).Ticks;
+
+            while (ticks > 0)
+            {
+                var length = ticks >= segmentLengthTicks ? segmentLengthTicks : ticks;
+
+                result.Add(TimeSpan.FromTicks(length).TotalSeconds);
+
+                ticks -= length;
+            }
+
+            return result.ToArray();
+        }
+
+        private string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding, int startNumber)
+        {
+            var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
+
+            var threads = _encodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec);
+
+            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 inputModifier = _encodingHelper.GetInputModifier(state, encodingOptions);
+
+            // If isEncoding is true we're actually starting ffmpeg
+            var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0";
+
+            var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
+
+            var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request.SegmentContainer);
+
+            var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
+            if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
+            {
+                segmentFormat = "mpegts";
+            }
+
+            return string.Format(
+                CultureInfo.InvariantCulture,
+                "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -f hls -max_delay 5000000 -hls_time {6} -individual_header_trailer 0 -hls_segment_type {7} -start_number {8} -hls_segment_filename \"{9}\" -hls_playlist_type vod -hls_list_size 0 -y \"{10}\"",
+                inputModifier,
+                _encodingHelper.GetInputArgument(state, encodingOptions),
+                threads,
+                mapArgs,
+                GetVideoArguments(state, encodingOptions, startNumber),
+                GetAudioArguments(state, encodingOptions),
+                state.SegmentLength.ToString(CultureInfo.InvariantCulture),
+                segmentFormat,
+                startNumberParam,
+                outputTsArg,
+                outputPath).Trim();
+        }
+
+        private string GetAudioArguments(StreamState state, EncodingOptions encodingOptions)
+        {
+            var audioCodec = _encodingHelper.GetAudioEncoder(state);
+
+            if (!state.IsOutputVideo)
+            {
+                if (EncodingHelper.IsCopyCodec(audioCodec))
+                {
+                    return "-acodec copy";
+                }
+
+                var audioTranscodeParams = new List<string>();
+
+                audioTranscodeParams.Add("-acodec " + audioCodec);
+
+                if (state.OutputAudioBitrate.HasValue)
+                {
+                    audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture));
+                }
+
+                if (state.OutputAudioChannels.HasValue)
+                {
+                    audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture));
+                }
+
+                if (state.OutputAudioSampleRate.HasValue)
+                {
+                    audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture));
+                }
+
+                audioTranscodeParams.Add("-vn");
+                return string.Join(' ', audioTranscodeParams);
+            }
+
+            if (EncodingHelper.IsCopyCodec(audioCodec))
+            {
+                var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
+
+                if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
+                {
+                    return "-codec:a:0 copy -copypriorss:a:0 0";
+                }
+
+                return "-codec:a:0 copy";
+            }
+
+            var args = "-codec:a:0 " + audioCodec;
+
+            var channels = state.OutputAudioChannels;
+
+            if (channels.HasValue)
+            {
+                args += " -ac " + channels.Value;
+            }
+
+            var bitrate = state.OutputAudioBitrate;
+
+            if (bitrate.HasValue)
+            {
+                args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture);
+            }
+
+            if (state.OutputAudioSampleRate.HasValue)
+            {
+                args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
+            }
+
+            args += " " + _encodingHelper.GetAudioFilterParam(state, encodingOptions, true);
+
+            return args;
+        }
+
+        private string GetVideoArguments(StreamState state, EncodingOptions encodingOptions, int startNumber)
+        {
+            if (!state.IsOutputVideo)
+            {
+                return string.Empty;
+            }
+
+            var codec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
+
+            var args = "-codec:v:0 " + codec;
+
+            // if (state.EnableMpegtsM2TsMode)
+            // {
+            //     args += " -mpegts_m2ts_mode 1";
+            // }
+
+            // See if we can save come cpu cycles by avoiding encoding
+            if (EncodingHelper.IsCopyCodec(codec))
+            {
+                if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
+                {
+                    string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream);
+                    if (!string.IsNullOrEmpty(bitStreamArgs))
+                    {
+                        args += " " + bitStreamArgs;
+                    }
+                }
+
+                // args += " -flags -global_header";
+            }
+            else
+            {
+                var gopArg = string.Empty;
+                var keyFrameArg = string.Format(
+                    CultureInfo.InvariantCulture,
+                    " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"",
+                    startNumber * state.SegmentLength,
+                    state.SegmentLength);
+
+                var framerate = state.VideoStream?.RealFrameRate;
+
+                if (framerate.HasValue)
+                {
+                    // This is to make sure keyframe interval is limited to our segment,
+                    // as forcing keyframes is not enough.
+                    // Example: we encoded half of desired length, then codec detected
+                    // scene cut and inserted a keyframe; next forced keyframe would
+                    // be created outside of segment, which breaks seeking
+                    // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe
+                    gopArg = string.Format(
+                        CultureInfo.InvariantCulture,
+                        " -g {0} -keyint_min {0} -sc_threshold 0",
+                        Math.Ceiling(state.SegmentLength * framerate.Value));
+                }
+
+                args += " " + _encodingHelper.GetVideoQualityParam(state, codec, encodingOptions, "veryfast");
+
+                // Unable to force key frames using these hw encoders, set key frames by GOP
+                if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase))
+                {
+                    args += " " + gopArg;
+                }
+                else
+                {
+                    args += " " + keyFrameArg + gopArg;
+                }
+
+                // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0";
+
+                var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
+
+                // This is for graphical subs
+                if (hasGraphicalSubs)
+                {
+                    args += _encodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec);
+                }
+
+                // Add resolution params, if specified
+                else
+                {
+                    args += _encodingHelper.GetOutputSizeParam(state, encodingOptions, codec);
+                }
+
+                // -start_at_zero is necessary to use with -ss when seeking,
+                // otherwise the target position cannot be determined.
+                if (!(state.SubtitleStream != null && state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream))
+                {
+                    args += " -start_at_zero";
+                }
+
+                // args += " -flags -global_header";
+            }
+
+            if (!string.IsNullOrEmpty(state.OutputVideoSync))
+            {
+                args += " -vsync " + state.OutputVideoSync;
+            }
+
+            args += _encodingHelper.GetOutputFFlags(state);
+
+            return args;
+        }
+
+        private string GetSegmentFileExtension(string? segmentContainer)
+        {
+            if (!string.IsNullOrWhiteSpace(segmentContainer))
+            {
+                return "." + segmentContainer;
+            }
+
+            return ".ts";
+        }
+
+        private string GetSegmentPath(StreamState state, string playlist, int index)
+        {
+            var folder = Path.GetDirectoryName(playlist);
+
+            var filename = Path.GetFileNameWithoutExtension(playlist);
+
+            return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + 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)
+            {
+                if (transcodingJob != 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, segmentIndex, transcodingJob);
+                }
+
+                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, segmentIndex, transcodingJob);
+                }
+            }
+
+            var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1);
+            if (transcodingJob != null)
+            {
+                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)
+                    {
+                        if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath))
+                        {
+                            _logger.LogDebug("serving up {0} as it deemed ready", segmentPath);
+                            return GetSegmentResult(state, segmentPath, segmentIndex, 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);
+                }
+
+                cancellationToken.ThrowIfCancellationRequested();
+            }
+            else
+            {
+                _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath);
+            }
+
+            return GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob);
+        }
+
+        private ActionResult GetSegmentResult(StreamState state, string segmentPath, int index, TranscodingJobDto? transcodingJob)
+        {
+            var segmentEndingPositionTicks = GetEndPositionTicks(state, index);
+
+            Response.OnCompleted(() =>
+            {
+                _logger.LogDebug("finished serving {0}", segmentPath);
+                if (transcodingJob != null)
+                {
+                    transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks);
+                    _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob);
+                }
+
+                return Task.CompletedTask;
+            });
+
+            return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)!, false, this);
+        }
+
+        private long GetEndPositionTicks(StreamState state, int requestedIndex)
+        {
+            double startSeconds = 0;
+            var lengths = GetSegmentLengths(state);
+
+            if (requestedIndex >= lengths.Length)
+            {
+                var msg = string.Format(
+                    CultureInfo.InvariantCulture,
+                    "Invalid segment index requested: {0} - Segment count: {1}",
+                    requestedIndex,
+                    lengths.Length);
+                throw new ArgumentException(msg);
+            }
+
+            for (var i = 0; i <= requestedIndex; i++)
+            {
+                startSeconds += lengths[i];
+            }
+
+            return TimeSpan.FromSeconds(startSeconds).Ticks;
+        }
+
+        private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension)
+        {
+            var job = _transcodingJobHelper.GetTranscodingJob(playlist, _transcodingJobType);
+
+            if (job == null || job.HasExited)
+            {
+                return null;
+            }
+
+            var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem);
+
+            if (file == null)
+            {
+                return null;
+            }
+
+            var playlistFilename = Path.GetFileNameWithoutExtension(playlist);
+
+            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);
+
+            var filePrefix = Path.GetFileNameWithoutExtension(playlist) ?? string.Empty;
+
+            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;
+            }
+        }
+
+        private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount)
+        {
+            var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem);
+
+            if (file != null)
+            {
+                DeleteFile(file.FullName, retryCount);
+            }
+        }
+
+        private void DeleteFile(string path, int retryCount)
+        {
+            if (retryCount >= 5)
+            {
+                return;
+            }
+
+            _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);
+
+                var task = Task.Delay(100);
+                Task.WaitAll(task);
+                DeleteFile(path, retryCount + 1);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error deleting partial stream file(s) {path}", path);
+            }
+        }
+
+        private long GetStartPositionTicks(StreamState state, int requestedIndex)
+        {
+            double startSeconds = 0;
+            var lengths = GetSegmentLengths(state);
+
+            if (requestedIndex >= lengths.Length)
+            {
+                var msg = string.Format(
+                    CultureInfo.InvariantCulture,
+                    "Invalid segment index requested: {0} - Segment count: {1}",
+                    requestedIndex,
+                    lengths.Length);
+                throw new ArgumentException(msg);
+            }
+
+            for (var i = 0; i < requestedIndex; i++)
+            {
+                startSeconds += lengths[i];
+            }
+
+            var position = TimeSpan.FromSeconds(startSeconds).Ticks;
+            return position;
+        }
+    }
+}

+ 191 - 0
Jellyfin.Api/Controllers/EnvironmentController.cs

@@ -0,0 +1,191 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.EnvironmentDtos;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Environment Controller.
+    /// </summary>
+    [Authorize(Policy = Policies.RequiresElevation)]
+    public class EnvironmentController : BaseJellyfinApiController
+    {
+        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;
+        }
+
+        /// <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)
+            {
+                return Array.Empty<FileSystemEntryInfo>();
+            }
+
+            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));
+        }
+
+        /// <summary>
+        /// Validates path.
+        /// </summary>
+        /// <param name="validatePathDto">Validate request object.</param>
+        /// <response code="200">Path validated.</response>
+        /// <response code="404">Path not found.</response>
+        /// <returns>Validation status.</returns>
+        [HttpPost("ValidatePath")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto)
+        {
+            if (validatePathDto.IsFile.HasValue)
+            {
+                if (validatePathDto.IsFile.Value)
+                {
+                    if (!System.IO.File.Exists(validatePathDto.Path))
+                    {
+                        return NotFound();
+                    }
+                }
+                else
+                {
+                    if (!Directory.Exists(validatePathDto.Path))
+                    {
+                        return NotFound();
+                    }
+                }
+            }
+            else
+            {
+                if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path))
+                {
+                    return NotFound();
+                }
+
+                if (validatePathDto.ValidateWritable)
+                {
+                    var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString());
+                    try
+                    {
+                        System.IO.File.WriteAllText(file, string.Empty);
+                    }
+                    finally
+                    {
+                        if (System.IO.File.Exists(file))
+                        {
+                            System.IO.File.Delete(file);
+                        }
+                    }
+                }
+            }
+
+            return Ok();
+        }
+
+        /// <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 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))
+            {
+                // Check if unc share
+                var index = path.LastIndexOf(UncSeparator);
+
+                if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0)
+                {
+                    parent = path.Substring(0, index);
+
+                    if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator)))
+                    {
+                        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();
+        }
+    }
+}

+ 218 - 0
Jellyfin.Api/Controllers/FilterController.cs

@@ -0,0 +1,218 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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] string? parentId,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? mediaTypes)
+        {
+            var parentItem = string.IsNullOrEmpty(parentId)
+                ? null
+                : _libraryManager.GetItemById(parentId);
+
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+            {
+                parentItem = null;
+            }
+
+            var item = string.IsNullOrEmpty(parentId)
+                ? user == null
+                    ? _libraryManager.RootFolder
+                    : _libraryManager.GetUserRootFolder()
+                : parentItem;
+
+            var query = new InternalItemsQuery
+            {
+                User = user,
+                MediaTypes = (mediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
+                IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
+                Recursive = true,
+                EnableTotalRecordCount = false,
+                DtoOptions = new DtoOptions
+                {
+                    Fields = new[] { ItemFields.Genres, ItemFields.Tags },
+                    EnableImages = false,
+                    EnableUserData = false
+                }
+            };
+
+            var itemList = ((Folder)item!).GetItemList(query);
+            return new QueryFiltersLegacy
+            {
+                Years = itemList.Select(i => i.ProductionYear ?? -1)
+                    .Where(i => i > 0)
+                    .Distinct()
+                    .OrderBy(i => i)
+                    .ToArray(),
+
+                Genres = itemList.SelectMany(i => i.Genres)
+                    .DistinctNames()
+                    .OrderBy(i => i)
+                    .ToArray(),
+
+                Tags = itemList
+                    .SelectMany(i => i.Tags)
+                    .Distinct(StringComparer.OrdinalIgnoreCase)
+                    .OrderBy(i => i)
+                    .ToArray(),
+
+                OfficialRatings = itemList
+                    .Select(i => i.OfficialRating)
+                    .Where(i => !string.IsNullOrWhiteSpace(i))
+                    .Distinct(StringComparer.OrdinalIgnoreCase)
+                    .OrderBy(i => i)
+                    .ToArray()
+            };
+        }
+
+        /// <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] string? parentId,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] bool? isAiring,
+            [FromQuery] bool? isMovie,
+            [FromQuery] bool? isSports,
+            [FromQuery] bool? isKids,
+            [FromQuery] bool? isNews,
+            [FromQuery] bool? isSeries,
+            [FromQuery] bool? recursive)
+        {
+            var parentItem = string.IsNullOrEmpty(parentId)
+                ? null
+                : _libraryManager.GetItemById(parentId);
+
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+            {
+                parentItem = null;
+            }
+
+            var filters = new QueryFilters();
+            var genreQuery = new InternalItemsQuery(user)
+            {
+                IncludeItemTypes =
+                    (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
+                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 == null ? Array.Empty<Guid>() : new[] { parentItem.Id };
+            }
+            else
+            {
+                genreQuery.Parent = parentItem;
+            }
+
+            if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase))
+            {
+                filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
+                {
+                    Name = i.Item1.Name,
+                    Id = i.Item1.Id
+                }).ToArray();
+            }
+            else
+            {
+                filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair
+                {
+                    Name = i.Item1.Name,
+                    Id = i.Item1.Id
+                }).ToArray();
+            }
+
+            return filters;
+        }
+    }
+}

+ 320 - 0
Jellyfin.Api/Controllers/GenresController.cs

@@ -0,0 +1,320 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Genre = MediaBrowser.Controller.Entities.Genre;
+
+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>
+        /// 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;
+        }
+
+        /// <summary>
+        /// Gets all genres 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">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="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="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="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 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="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="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] double? minCommunityRating,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? searchTerm,
+            [FromQuery] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? genres,
+            [FromQuery] string? genreIds,
+            [FromQuery] string? officialRatings,
+            [FromQuery] string? tags,
+            [FromQuery] string? years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] string? person,
+            [FromQuery] string? personIds,
+            [FromQuery] string? personTypes,
+            [FromQuery] string? studios,
+            [FromQuery] string? studioIds,
+            [FromQuery] Guid? userId,
+            [FromQuery] string? nameStartsWithOrGreater,
+            [FromQuery] string? nameStartsWith,
+            [FromQuery] string? nameLessThan,
+            [FromQuery] bool? enableImages = true,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            User? user = null;
+            BaseItem parentItem;
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                user = _userManager.GetUserById(userId.Value);
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
+            }
+            else
+            {
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
+            }
+
+            var query = new InternalItemsQuery(user)
+            {
+                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+                StartIndex = startIndex,
+                Limit = limit,
+                IsFavorite = isFavorite,
+                NameLessThan = nameLessThan,
+                NameStartsWith = nameStartsWith,
+                NameStartsWithOrGreater = nameStartsWithOrGreater,
+                Tags = RequestHelpers.Split(tags, '|', true),
+                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
+                Genres = RequestHelpers.Split(genres, '|', true),
+                GenreIds = RequestHelpers.GetGuids(genreIds),
+                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Person = person,
+                PersonIds = RequestHelpers.GetGuids(personIds),
+                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
+                Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(),
+                MinCommunityRating = minCommunityRating,
+                DtoOptions = dtoOptions,
+                SearchTerm = searchTerm,
+                EnableTotalRecordCount = enableTotalRecordCount
+            };
+
+            if (!string.IsNullOrWhiteSpace(parentId))
+            {
+                if (parentItem is Folder)
+                {
+                    query.AncestorIds = new[] { new Guid(parentId) };
+                }
+                else
+                {
+                    query.ItemIds = new[] { new Guid(parentId) };
+                }
+            }
+
+            // Studios
+            if (!string.IsNullOrEmpty(studios))
+            {
+                query.StudioIds = studios.Split('|')
+                    .Select(i =>
+                    {
+                        try
+                        {
+                            return _libraryManager.GetStudio(i);
+                        }
+                        catch
+                        {
+                            return null;
+                        }
+                    }).Where(i => i != null)
+                    .Select(i => i!.Id)
+                    .ToArray();
+            }
+
+            foreach (var filter in RequestHelpers.GetFilters(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 = new QueryResult<(BaseItem, ItemCounts)>();
+
+            var dtos = result.Items.Select(i =>
+            {
+                var (baseItem, counts) = i;
+                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+                if (!string.IsNullOrWhiteSpace(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>
+            {
+                Items = dtos.ToArray(),
+                TotalRecordCount = result.TotalRecordCount
+            };
+        }
+
+        /// <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] string genreName, [FromQuery] Guid? userId)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddClientFields(Request);
+
+            Genre item = new Genre();
+            if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1)
+            {
+                var result = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions);
+
+                if (result != null)
+                {
+                    item = result;
+                }
+            }
+            else
+            {
+                item = _libraryManager.GetGenre(genreName);
+            }
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                var user = _userManager.GetUserById(userId.Value);
+
+                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+            }
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions);
+        }
+
+        private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions)
+            where T : BaseItem, new()
+        {
+            var result = libraryManager.GetItemList(new InternalItemsQuery
+            {
+                Name = name.Replace(BaseItem.SlugChar, '&'),
+                IncludeItemTypes = new[] { typeof(T).Name },
+                DtoOptions = dtoOptions
+            }).OfType<T>().FirstOrDefault();
+
+            result ??= libraryManager.GetItemList(new InternalItemsQuery
+            {
+                Name = name.Replace(BaseItem.SlugChar, '/'),
+                IncludeItemTypes = new[] { typeof(T).Name },
+                DtoOptions = dtoOptions
+            }).OfType<T>().FirstOrDefault();
+
+            result ??= libraryManager.GetItemList(new InternalItemsQuery
+            {
+                Name = name.Replace(BaseItem.SlugChar, '?'),
+                IncludeItemTypes = new[] { typeof(T).Name },
+                DtoOptions = dtoOptions
+            }).OfType<T>().FirstOrDefault();
+
+            return result;
+        }
+    }
+}

+ 154 - 0
Jellyfin.Api/Controllers/HlsSegmentController.cs

@@ -0,0 +1,154 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
+        public ActionResult GetHlsAudioSegmentLegacy([FromRoute] string itemId, [FromRoute] string segmentId)
+        {
+            // TODO: Deprecate with new iOS app
+            var file = segmentId + Path.GetExtension(Request.Path);
+            file = Path.Combine(_serverConfigurationManager.GetTranscodePath(), file);
+
+            return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)!, false, this);
+        }
+
+        /// <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)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
+        public ActionResult GetHlsPlaylistLegacy([FromRoute] string itemId, [FromRoute] string playlistId)
+        {
+            var file = playlistId + Path.GetExtension(Request.Path);
+            file = Path.Combine(_serverConfigurationManager.GetTranscodePath(), 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] string deviceId, [FromQuery] 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>
+        /// <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)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
+        public ActionResult GetHlsVideoSegmentLegacy(
+            [FromRoute] string itemId,
+            [FromRoute] string playlistId,
+            [FromRoute] string segmentId,
+            [FromRoute] string segmentContainer)
+        {
+            var file = segmentId + Path.GetExtension(Request.Path);
+            var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
+
+            file = Path.Combine(transcodeFolderPath, file);
+
+            var normalizedPlaylistId = playlistId;
+
+            var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath)
+                .FirstOrDefault(i =>
+                    string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase)
+                    && i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1);
+
+            return GetFileResult(file, playlistPath);
+        }
+
+        private ActionResult GetFileResult(string path, string playlistPath)
+        {
+            var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);
+
+            Response.OnCompleted(() =>
+            {
+                if (transcodingJob != null)
+                {
+                    _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob);
+                }
+
+                return Task.CompletedTask;
+            });
+
+            return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)!, false, this);
+        }
+    }
+}

+ 231 - 0
Jellyfin.Api/Controllers/ImageByNameController.cs

@@ -0,0 +1,231 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    ///     Images By Name Controller.
+    /// </summary>
+    [Route("Images")]
+    public class ImageByNameController : BaseJellyfinApiController
+    {
+        private readonly IServerApplicationPaths _applicationPaths;
+        private readonly IFileSystem _fileSystem;
+
+        /// <summary>
+        ///     Initializes a new instance of the <see cref="ImageByNameController" /> class.
+        /// </summary>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager" /> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem" /> interface.</param>
+        public ImageByNameController(
+            IServerConfigurationManager serverConfigurationManager,
+            IFileSystem fileSystem)
+        {
+            _applicationPaths = serverConfigurationManager.ApplicationPaths;
+            _fileSystem = fileSystem;
+        }
+
+        /// <summary>
+        ///     Get all general images.
+        /// </summary>
+        /// <response code="200">Retrieved list of images.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of images.</returns>
+        [HttpGet("General")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<ImageByNameInfo>> GetGeneralImages()
+        {
+            return GetImageList(_applicationPaths.GeneralPath, false);
+        }
+
+        /// <summary>
+        ///     Get General Image.
+        /// </summary>
+        /// <param name="name">The name of the image.</param>
+        /// <param name="type">Image Type (primary, backdrop, logo, etc).</param>
+        /// <response code="200">Image stream retrieved.</response>
+        /// <response code="404">Image not found.</response>
+        /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+        [HttpGet("General/{name}/{type}")]
+        [AllowAnonymous]
+        [Produces(MediaTypeNames.Application.Octet)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<FileStreamResult> GetGeneralImage([FromRoute, Required] string? name, [FromRoute, Required] string? type)
+        {
+            var filename = string.Equals(type, "primary", StringComparison.OrdinalIgnoreCase)
+                ? "folder"
+                : type;
+
+            var path = BaseItem.SupportedImageExtensions
+                .Select(i => Path.Combine(_applicationPaths.GeneralPath, name, filename + i))
+                .FirstOrDefault(System.IO.File.Exists);
+
+            if (path == null)
+            {
+                return NotFound();
+            }
+
+            var contentType = MimeTypes.GetMimeType(path);
+            return File(System.IO.File.OpenRead(path), contentType);
+        }
+
+        /// <summary>
+        ///     Get all general images.
+        /// </summary>
+        /// <response code="200">Retrieved list of images.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of images.</returns>
+        [HttpGet("Ratings")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<ImageByNameInfo>> GetRatingImages()
+        {
+            return GetImageList(_applicationPaths.RatingsPath, false);
+        }
+
+        /// <summary>
+        ///     Get rating image.
+        /// </summary>
+        /// <param name="theme">The theme to get the image from.</param>
+        /// <param name="name">The name of the image.</param>
+        /// <response code="200">Image stream retrieved.</response>
+        /// <response code="404">Image not found.</response>
+        /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+        [HttpGet("Ratings/{theme}/{name}")]
+        [AllowAnonymous]
+        [Produces(MediaTypeNames.Application.Octet)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<FileStreamResult> GetRatingImage(
+            [FromRoute, Required] string? theme,
+            [FromRoute, Required] string? name)
+        {
+            return GetImageFile(_applicationPaths.RatingsPath, theme, name);
+        }
+
+        /// <summary>
+        ///     Get all media info images.
+        /// </summary>
+        /// <response code="200">Image list retrieved.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of images.</returns>
+        [HttpGet("MediaInfo")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<ImageByNameInfo>> GetMediaInfoImages()
+        {
+            return GetImageList(_applicationPaths.MediaInfoImagesPath, false);
+        }
+
+        /// <summary>
+        ///     Get media info image.
+        /// </summary>
+        /// <param name="theme">The theme to get the image from.</param>
+        /// <param name="name">The name of the image.</param>
+        /// <response code="200">Image stream retrieved.</response>
+        /// <response code="404">Image not found.</response>
+        /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+        [HttpGet("MediaInfo/{theme}/{name}")]
+        [AllowAnonymous]
+        [Produces(MediaTypeNames.Application.Octet)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<FileStreamResult> GetMediaInfoImage(
+            [FromRoute, Required] string? theme,
+            [FromRoute, Required] string? name)
+        {
+            return GetImageFile(_applicationPaths.MediaInfoImagesPath, theme, name);
+        }
+
+        /// <summary>
+        ///     Internal FileHelper.
+        /// </summary>
+        /// <param name="basePath">Path to begin search.</param>
+        /// <param name="theme">Theme to search.</param>
+        /// <param name="name">File name to search for.</param>
+        /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+        private ActionResult<FileStreamResult> GetImageFile(string basePath, string? theme, string? name)
+        {
+            var themeFolder = Path.Combine(basePath, theme);
+            if (Directory.Exists(themeFolder))
+            {
+                var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(themeFolder, name + i))
+                    .FirstOrDefault(System.IO.File.Exists);
+
+                if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
+                {
+                    var contentType = MimeTypes.GetMimeType(path);
+                    return File(System.IO.File.OpenRead(path), contentType);
+                }
+            }
+
+            var allFolder = Path.Combine(basePath, "all");
+            if (Directory.Exists(allFolder))
+            {
+                var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(allFolder, name + i))
+                    .FirstOrDefault(System.IO.File.Exists);
+
+                if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
+                {
+                    var contentType = MimeTypes.GetMimeType(path);
+                    return File(System.IO.File.OpenRead(path), contentType);
+                }
+            }
+
+            return NotFound();
+        }
+
+        private List<ImageByNameInfo> GetImageList(string path, bool supportsThemes)
+        {
+            try
+            {
+                return _fileSystem.GetFiles(path, BaseItem.SupportedImageExtensions, false, true)
+                    .Select(i => new ImageByNameInfo
+                    {
+                        Name = _fileSystem.GetFileNameWithoutExtension(i),
+                        FileLength = i.Length,
+
+                        // For themeable images, use the Theme property
+                        // For general images, the same object structure is fine,
+                        // but it's not owned by a theme, so call it Context
+                        Theme = supportsThemes ? GetThemeName(i.FullName, path) : null,
+                        Context = supportsThemes ? null : GetThemeName(i.FullName, path),
+                        Format = i.Extension.ToLowerInvariant().TrimStart('.')
+                    })
+                    .OrderBy(i => i.Name)
+                    .ToList();
+            }
+            catch (IOException)
+            {
+                return new List<ImageByNameInfo>();
+            }
+        }
+
+        private string? GetThemeName(string path, string rootImagePath)
+        {
+            var parentName = Path.GetDirectoryName(path);
+
+            if (string.Equals(parentName, rootImagePath, StringComparison.OrdinalIgnoreCase))
+            {
+                return null;
+            }
+
+            parentName = Path.GetFileName(parentName);
+
+            return string.Equals(parentName, "all", StringComparison.OrdinalIgnoreCase) ? null : parentName;
+        }
+    }
+}

+ 1304 - 0
Jellyfin.Api/Controllers/ImageController.cs

@@ -0,0 +1,1304 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Drawing;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using Microsoft.Net.Http.Headers;
+
+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 IAuthorizationContext _authContext;
+        private readonly ILogger<ImageController> _logger;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+
+        /// <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="authContext">Instance of the <see cref="IAuthorizationContext"/> 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>
+        public ImageController(
+            IUserManager userManager,
+            ILibraryManager libraryManager,
+            IProviderManager providerManager,
+            IImageProcessor imageProcessor,
+            IFileSystem fileSystem,
+            IAuthorizationContext authContext,
+            ILogger<ImageController> logger,
+            IServerConfigurationManager serverConfigurationManager)
+        {
+            _userManager = userManager;
+            _libraryManager = libraryManager;
+            _providerManager = providerManager;
+            _imageProcessor = imageProcessor;
+            _fileSystem = fileSystem;
+            _authContext = authContext;
+            _logger = logger;
+            _serverConfigurationManager = serverConfigurationManager;
+        }
+
+        /// <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}")]
+        [HttpPost("Users/{userId}/Images/{imageType}/{index?}", Name = "PostUserImage_2")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [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] Guid userId,
+            [FromRoute] ImageType imageType,
+            [FromRoute] int? index = null)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+            {
+                return Forbid("User is not allowed to update the image.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+            await using var memoryStream = await GetMemoryStream(Request.Body).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 != null)
+            {
+                _userManager.ClearProfileImage(user);
+            }
+
+            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType)));
+
+            await _providerManager
+                .SaveImage(user, memoryStream, mimeType, user.ProfileImage.Path)
+                .ConfigureAwait(false);
+            await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+
+            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/{itemType}")]
+        [HttpDelete("Users/{userId}/Images/{itemType}/{index?}", Name = "DeleteUserImage_2")]
+        [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 ActionResult DeleteUserImage(
+            [FromRoute] Guid userId,
+            [FromRoute] ImageType imageType,
+            [FromRoute] int? index = null)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+            {
+                return Forbid("User is not allowed to delete the image.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+            try
+            {
+                System.IO.File.Delete(user.ProfileImage.Path);
+            }
+            catch (IOException e)
+            {
+                _logger.LogError(e, "Error deleting user profile image:");
+            }
+
+            _userManager.ClearProfileImage(user);
+            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}")]
+        [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "DeleteItemImage_2")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult DeleteItemImage(
+            [FromRoute] Guid itemId,
+            [FromRoute] ImageType imageType,
+            [FromRoute] int? imageIndex = null)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            item.DeleteImage(imageType, imageIndex ?? 0);
+            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}")]
+        [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "SetItemImage_2")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+        public async Task<ActionResult> SetItemImage(
+            [FromRoute] Guid itemId,
+            [FromRoute] ImageType imageType,
+            [FromRoute] int? imageIndex = null)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType.Split(';').FirstOrDefault();
+            await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+            item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <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 ActionResult UpdateItemImageIndex(
+            [FromRoute] Guid itemId,
+            [FromRoute] ImageType imageType,
+            [FromRoute] int imageIndex,
+            [FromQuery] int newIndex)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            item.SwapImages(imageType, imageIndex, newIndex);
+            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 ActionResult<IEnumerable<ImageInfo>> GetItemImageInfos([FromRoute] Guid itemId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            var list = new List<ImageInfo>();
+            var itemImages = item.ImageInfos;
+
+            if (itemImages.Length == 0)
+            {
+                // short-circuit
+                return list;
+            }
+
+            _libraryManager.UpdateImages(item); // this makes sure dimensions and hashes are correct
+
+            foreach (var image in itemImages)
+            {
+                if (!item.AllowsMultipleImages(image.Type))
+                {
+                    var info = GetImageInfo(item, image, null);
+
+                    if (info != null)
+                    {
+                        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 image in itemImages.Where(i => i.Type == currentImageType))
+                {
+                    var info = GetImageInfo(item, image, index);
+
+                    if (info != null)
+                    {
+                        list.Add(info);
+                    }
+
+                    index++;
+                }
+            }
+
+            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="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="addPlayedIndicator">Optional. Add a played indicator.</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")]
+        [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "GetItemImage_2")]
+        [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> GetItemImage(
+            [FromRoute] Guid itemId,
+            [FromRoute] ImageType imageType,
+            [FromRoute] int? maxWidth,
+            [FromRoute] int? maxHeight,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] string? tag,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] string? format,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromRoute] int? imageIndex = null)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    itemId,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .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="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="addPlayedIndicator">Optional. Add a played indicator.</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)]
+        public async Task<ActionResult> GetItemImage2(
+            [FromRoute] Guid itemId,
+            [FromRoute] ImageType imageType,
+            [FromRoute] int? maxWidth,
+            [FromRoute] int? maxHeight,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromRoute] string tag,
+            [FromQuery] bool? cropWhitespace,
+            [FromRoute] string format,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromRoute] double? percentPlayed,
+            [FromRoute] int? unplayedCount,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromRoute] int? imageIndex = null)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    itemId,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .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="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="addPlayedIndicator">Optional. Add a played indicator.</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)]
+        public async Task<ActionResult> GetArtistImage(
+            [FromRoute] string name,
+            [FromRoute] ImageType imageType,
+            [FromRoute] string tag,
+            [FromRoute] string format,
+            [FromRoute] int? maxWidth,
+            [FromRoute] int? maxHeight,
+            [FromRoute] double? percentPlayed,
+            [FromRoute] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromRoute] int? imageIndex = null)
+        {
+            var item = _libraryManager.GetArtist(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .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="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="addPlayedIndicator">Optional. Add a played indicator.</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}/{imageIndex?}")]
+        [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadGenreImage")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> GetGenreImage(
+            [FromRoute] string name,
+            [FromRoute] ImageType imageType,
+            [FromRoute] string tag,
+            [FromRoute] string format,
+            [FromRoute] int? maxWidth,
+            [FromRoute] int? maxHeight,
+            [FromRoute] double? percentPlayed,
+            [FromRoute] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromRoute] int? imageIndex = null)
+        {
+            var item = _libraryManager.GetGenre(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .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="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="addPlayedIndicator">Optional. Add a played indicator.</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}/{imageIndex?}")]
+        [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadMusicGenreImage")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> GetMusicGenreImage(
+            [FromRoute] string name,
+            [FromRoute] ImageType imageType,
+            [FromRoute] string tag,
+            [FromRoute] string format,
+            [FromRoute] int? maxWidth,
+            [FromRoute] int? maxHeight,
+            [FromRoute] double? percentPlayed,
+            [FromRoute] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromRoute] int? imageIndex = null)
+        {
+            var item = _libraryManager.GetMusicGenre(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .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="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="addPlayedIndicator">Optional. Add a played indicator.</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}/{imageIndex?}")]
+        [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadPersonImage")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> GetPersonImage(
+            [FromRoute] string name,
+            [FromRoute] ImageType imageType,
+            [FromRoute] string tag,
+            [FromRoute] string format,
+            [FromRoute] int? maxWidth,
+            [FromRoute] int? maxHeight,
+            [FromRoute] double? percentPlayed,
+            [FromRoute] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromRoute] int? imageIndex = null)
+        {
+            var item = _libraryManager.GetPerson(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .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="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="addPlayedIndicator">Optional. Add a played indicator.</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}/{imageIndex?}")]
+        [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadStudioImage")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> GetStudioImage(
+            [FromRoute] string name,
+            [FromRoute] ImageType imageType,
+            [FromRoute] string tag,
+            [FromRoute] string format,
+            [FromRoute] int? maxWidth,
+            [FromRoute] int? maxHeight,
+            [FromRoute] double? percentPlayed,
+            [FromRoute] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromRoute] int? imageIndex = null)
+        {
+            var item = _libraryManager.GetStudio(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .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="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="addPlayedIndicator">Optional. Add a played indicator.</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}/{imageIndex?}")]
+        [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex?}", Name = "HeadUserImage")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> GetUserImage(
+            [FromRoute] Guid userId,
+            [FromRoute] ImageType imageType,
+            [FromQuery] string? tag,
+            [FromQuery] string? format,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromRoute] int? imageIndex = null)
+        {
+            var user = _userManager.GetUserById(userId);
+            if (user == null)
+            {
+                return NotFound();
+            }
+
+            var info = new ItemImageInfo
+            {
+                Path = user.ProfileImage.Path,
+                Type = ImageType.Profile,
+                DateModified = user.ProfileImage.LastModified
+            };
+
+            if (width.HasValue)
+            {
+                info.Width = width.Value;
+            }
+
+            if (height.HasValue)
+            {
+                info.Height = height.Value;
+            }
+
+            return await GetImageInternal(
+                    user.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    null,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase),
+                    info)
+                .ConfigureAwait(false);
+        }
+
+        private static async Task<MemoryStream> GetMemoryStream(Stream inputStream)
+        {
+            using var reader = new StreamReader(inputStream);
+            var text = await reader.ReadToEndAsync().ConfigureAwait(false);
+
+            var bytes = Convert.FromBase64String(text);
+            return new MemoryStream(bytes) { Position = 0 };
+        }
+
+        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;
+
+                    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);
+            }
+
+            try
+            {
+                return new ImageInfo
+                {
+                    Path = info.Path,
+                    ImageIndex = imageIndex,
+                    ImageType = info.Type,
+                    ImageTag = _imageProcessor.GetImageCacheTag(item, info),
+                    Size = length,
+                    BlurHash = blurhash,
+                    Width = width,
+                    Height = height
+                };
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error getting image information for {Path}", info.Path);
+                return null;
+            }
+        }
+
+        private async Task<ActionResult> GetImageInternal(
+            Guid itemId,
+            ImageType imageType,
+            int? imageIndex,
+            string? tag,
+            string? format,
+            int? maxWidth,
+            int? maxHeight,
+            double? percentPlayed,
+            int? unplayedCount,
+            int? width,
+            int? height,
+            int? quality,
+            bool? cropWhitespace,
+            bool? addPlayedIndicator,
+            int? blur,
+            string? backgroundColor,
+            string? foregroundLayer,
+            BaseItem? item,
+            bool isHeadRequest,
+            ItemImageInfo? imageInfo = null)
+        {
+            if (percentPlayed.HasValue)
+            {
+                if (percentPlayed.Value <= 0)
+                {
+                    percentPlayed = null;
+                }
+                else if (percentPlayed.Value >= 100)
+                {
+                    percentPlayed = null;
+                    addPlayedIndicator = true;
+                }
+            }
+
+            if (percentPlayed.HasValue)
+            {
+                unplayedCount = null;
+            }
+
+            if (unplayedCount.HasValue
+                && unplayedCount.Value <= 0)
+            {
+                unplayedCount = null;
+            }
+
+            if (imageInfo == null)
+            {
+                imageInfo = item?.GetImageInfo(imageType, imageIndex ?? 0);
+                if (imageInfo == null)
+                {
+                    return NotFound(string.Format(NumberFormatInfo.InvariantInfo, "{0} does not have an image of type {1}", item?.Name, imageType));
+                }
+            }
+
+            cropWhitespace ??= imageType == ImageType.Logo || imageType == ImageType.Art;
+
+            var outputFormats = GetOutputFormats(format);
+
+            TimeSpan? cacheDuration = null;
+
+            if (!string.IsNullOrEmpty(tag))
+            {
+                cacheDuration = TimeSpan.FromDays(365);
+            }
+
+            var responseHeaders = new Dictionary<string, string>
+            {
+                { "transferMode.dlna.org", "Interactive" },
+                { "realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*" }
+            };
+
+            return await GetImageResult(
+                item,
+                itemId,
+                imageIndex,
+                height,
+                maxHeight,
+                maxWidth,
+                quality,
+                width,
+                addPlayedIndicator,
+                percentPlayed,
+                unplayedCount,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                imageInfo,
+                cropWhitespace.Value,
+                outputFormats,
+                cacheDuration,
+                responseHeaders,
+                isHeadRequest).ConfigureAwait(false);
+        }
+
+        private ImageFormat[] GetOutputFormats(string? format)
+        {
+            if (!string.IsNullOrWhiteSpace(format)
+                && Enum.TryParse(format, true, out ImageFormat parsedFormat))
+            {
+                return new[] { parsedFormat };
+            }
+
+            return GetClientSupportedFormats();
+        }
+
+        private ImageFormat[] GetClientSupportedFormats()
+        {
+            var acceptTypes = Request.Headers[HeaderNames.Accept];
+            var supportedFormats = new List<string>();
+            if (acceptTypes.Count > 0)
+            {
+                foreach (var type in acceptTypes)
+                {
+                    int index = type.IndexOf(';', StringComparison.Ordinal);
+                    if (index != -1)
+                    {
+                        supportedFormats.Add(type.Substring(0, index));
+                    }
+                }
+            }
+
+            var acceptParam = Request.Query[HeaderNames.Accept];
+
+            var supportsWebP = SupportsFormat(supportedFormats, acceptParam, "webp", false);
+
+            if (!supportsWebP)
+            {
+                var userAgent = Request.Headers[HeaderNames.UserAgent].ToString();
+                if (userAgent.IndexOf("crosswalk", StringComparison.OrdinalIgnoreCase) != -1 &&
+                    userAgent.IndexOf("android", StringComparison.OrdinalIgnoreCase) != -1)
+                {
+                    supportsWebP = true;
+                }
+            }
+
+            var formats = new List<ImageFormat>(4);
+
+            if (supportsWebP)
+            {
+                formats.Add(ImageFormat.Webp);
+            }
+
+            formats.Add(ImageFormat.Jpg);
+            formats.Add(ImageFormat.Png);
+
+            if (SupportsFormat(supportedFormats, acceptParam, "gif", true))
+            {
+                formats.Add(ImageFormat.Gif);
+            }
+
+            return formats.ToArray();
+        }
+
+        private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string acceptParam, string format, bool acceptAll)
+        {
+            var mimeType = "image/" + format;
+
+            if (requestAcceptTypes.Contains(mimeType))
+            {
+                return true;
+            }
+
+            if (acceptAll && requestAcceptTypes.Contains("*/*"))
+            {
+                return true;
+            }
+
+            return string.Equals(acceptParam, format, StringComparison.OrdinalIgnoreCase);
+        }
+
+        private async Task<ActionResult> GetImageResult(
+            BaseItem? item,
+            Guid itemId,
+            int? index,
+            int? height,
+            int? maxHeight,
+            int? maxWidth,
+            int? quality,
+            int? width,
+            bool? addPlayedIndicator,
+            double? percentPlayed,
+            int? unplayedCount,
+            int? blur,
+            string? backgroundColor,
+            string? foregroundLayer,
+            ItemImageInfo imageInfo,
+            bool cropWhitespace,
+            IReadOnlyCollection<ImageFormat> supportedFormats,
+            TimeSpan? cacheDuration,
+            IDictionary<string, string> headers,
+            bool isHeadRequest)
+        {
+            if (!imageInfo.IsLocalFile && item != null)
+            {
+                imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, index ?? 0).ConfigureAwait(false);
+            }
+
+            var options = new ImageProcessingOptions
+            {
+                CropWhiteSpace = cropWhitespace,
+                Height = height,
+                ImageIndex = index ?? 0,
+                Image = imageInfo,
+                Item = item,
+                ItemId = itemId,
+                MaxHeight = maxHeight,
+                MaxWidth = maxWidth,
+                Quality = quality ?? 100,
+                Width = width,
+                AddPlayedIndicator = addPlayedIndicator ?? false,
+                PercentPlayed = percentPlayed ?? 0,
+                UnplayedCount = unplayedCount,
+                Blur = blur,
+                BackgroundColor = backgroundColor,
+                ForegroundLayer = foregroundLayer,
+                SupportedOutputFormats = supportedFormats
+            };
+
+            var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(options).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;
+            }
+
+            foreach (var (key, value) in headers)
+            {
+                Response.Headers.Add(key, value);
+            }
+
+            Response.ContentType = imageContentType;
+            Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture));
+            Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept);
+
+            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)
+                {
+                    Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds);
+                }
+                else
+                {
+                    Response.Headers.Add(HeaderNames.CacheControl, "public");
+                }
+
+                Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", new CultureInfo("en-US", false)));
+
+                // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified
+                if (!(dateImageModified > ifModifiedSinceHeader))
+                {
+                    if (ifModifiedSinceHeader.Add(cacheDuration!.Value) < DateTime.UtcNow)
+                    {
+                        Response.StatusCode = StatusCodes.Status304NotModified;
+                        return new ContentResult();
+                    }
+                }
+            }
+
+            // if the request is a head request, return a NoContent result with the same headers as it would with a GET request
+            if (isHeadRequest)
+            {
+                return NoContent();
+            }
+
+            var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read);
+            return File(stream, imageContentType);
+        }
+    }
+}

+ 330 - 0
Jellyfin.Api/Controllers/InstantMixController.cs

@@ -0,0 +1,330 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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;
+        }
+
+        /// <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. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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] Guid id,
+            [FromQuery] Guid? userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var item = _libraryManager.GetItemById(id);
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .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. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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] Guid id,
+            [FromQuery] Guid? userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var album = _libraryManager.GetItemById(id);
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .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 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. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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] Guid id,
+            [FromQuery] Guid? userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var playlist = (Playlist)_libraryManager.GetItemById(id);
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .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 song.
+        /// </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. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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>> GetInstantMixFromMusicGenre(
+            [FromRoute, Required] string? name,
+            [FromQuery] Guid? userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .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 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. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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)]
+        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
+            [FromRoute] Guid id,
+            [FromQuery] Guid? userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var item = _libraryManager.GetItemById(id);
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .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. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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>> GetInstantMixFromMusicGenres(
+            [FromRoute] Guid id,
+            [FromQuery] Guid? userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var item = _libraryManager.GetItemById(id);
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .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. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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] Guid id,
+            [FromQuery] Guid? userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var item = _libraryManager.GetItemById(id);
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .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;
+
+            var result = new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = list.Count
+            };
+
+            if (limit.HasValue)
+            {
+                list = list.Take(limit.Value).ToList();
+            }
+
+            var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);
+
+            result.Items = returnList;
+
+            return result;
+        }
+    }
+}

+ 364 - 0
Jellyfin.Api/Controllers/ItemLookupController.cs

@@ -0,0 +1,364 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Providers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Item lookup controller.
+    /// </summary>
+    [Route("")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class ItemLookupController : BaseJellyfinApiController
+    {
+        private readonly IProviderManager _providerManager;
+        private readonly IServerApplicationPaths _appPaths;
+        private readonly IFileSystem _fileSystem;
+        private readonly ILibraryManager _libraryManager;
+        private readonly ILogger<ItemLookupController> _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="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> 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,
+            IServerConfigurationManager serverConfigurationManager,
+            IFileSystem fileSystem,
+            ILibraryManager libraryManager,
+            ILogger<ItemLookupController> logger)
+        {
+            _providerManager = providerManager;
+            _appPaths = serverConfigurationManager.ApplicationPaths;
+            _fileSystem = fileSystem;
+            _libraryManager = libraryManager;
+            _logger = 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] Guid itemId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            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 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 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 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 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>
+        /// Gets a remote image.
+        /// </summary>
+        /// <param name="imageUrl">The image url.</param>
+        /// <param name="providerName">The provider name.</param>
+        /// <response code="200">Remote image retrieved.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="FileStreamResult"/> containing the images file stream.
+        /// </returns>
+        [HttpGet("Items/RemoteSearch/Image")]
+        public async Task<ActionResult> GetRemoteSearchImage(
+            [FromQuery, Required] string imageUrl,
+            [FromQuery, Required] string providerName)
+        {
+            var urlHash = imageUrl.GetMD5();
+            var pointerCachePath = GetFullCachePath(urlHash.ToString());
+
+            try
+            {
+                var contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false);
+                if (System.IO.File.Exists(contentPath))
+                {
+                    await using var fileStreamExisting = System.IO.File.OpenRead(pointerCachePath);
+                    return new FileStreamResult(fileStreamExisting, MediaTypeNames.Application.Octet);
+                }
+            }
+            catch (FileNotFoundException)
+            {
+                // Means the file isn't cached yet
+            }
+            catch (IOException)
+            {
+                // Means the file isn't cached yet
+            }
+
+            await DownloadImage(providerName, imageUrl, urlHash, pointerCachePath).ConfigureAwait(false);
+
+            // Read the pointer file again
+            await using var fileStream = System.IO.File.OpenRead(pointerCachePath);
+            return new FileStreamResult(fileStream, MediaTypeNames.Application.Octet);
+        }
+
+        /// <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/{id}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        public async Task<ActionResult> ApplySearchCriteria(
+            [FromRoute] Guid itemId,
+            [FromBody, Required] RemoteSearchResult searchResult,
+            [FromQuery] bool replaceAllImages = true)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            _logger.LogInformation(
+                "Setting provider id's to item {0}-{1}: {2}",
+                item.Id,
+                item.Name,
+                JsonSerializer.Serialize(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
+                }, CancellationToken.None).ConfigureAwait(false);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Downloads the image.
+        /// </summary>
+        /// <param name="providerName">Name of the provider.</param>
+        /// <param name="url">The URL.</param>
+        /// <param name="urlHash">The URL hash.</param>
+        /// <param name="pointerCachePath">The pointer cache path.</param>
+        /// <returns>Task.</returns>
+        private async Task DownloadImage(string providerName, string url, Guid urlHash, string pointerCachePath)
+        {
+            var result = await _providerManager.GetSearchImage(providerName, url, CancellationToken.None).ConfigureAwait(false);
+            var ext = result.ContentType.Split('/').Last();
+            var fullCachePath = GetFullCachePath(urlHash + "." + ext);
+
+            Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath));
+            await using (var stream = result.Content)
+            {
+                await using var fileStream = new FileStream(
+                    fullCachePath,
+                    FileMode.Create,
+                    FileAccess.Write,
+                    FileShare.Read,
+                    IODefaults.FileStreamBufferSize,
+                    true);
+
+                await stream.CopyToAsync(fileStream).ConfigureAwait(false);
+            }
+
+            Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath));
+            await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Gets the full cache path.
+        /// </summary>
+        /// <param name="filename">The filename.</param>
+        /// <returns>System.String.</returns>
+        private string GetFullCachePath(string filename)
+            => Path.Combine(_appPaths.CachePath, "remote-images", filename.Substring(0, 1), filename);
+    }
+}

+ 85 - 0
Jellyfin.Api/Controllers/ItemRefreshController.cs

@@ -0,0 +1,85 @@
+using System;
+using System.ComponentModel;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Item Refresh Controller.
+    /// </summary>
+    [Route("Items")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class ItemRefreshController : BaseJellyfinApiController
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly IProviderManager _providerManager;
+        private readonly IFileSystem _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)
+        {
+            _libraryManager = libraryManager;
+            _providerManager = providerManager;
+            _fileSystem = 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 Post(
+            [FromRoute] 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 == null)
+            {
+                return NotFound();
+            }
+
+            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();
+        }
+    }
+}

+ 239 - 187
MediaBrowser.Api/ItemUpdateService.cs → Jellyfin.Api/Controllers/ItemUpdateController.cs

@@ -1,215 +1,100 @@
-using System;
+using System;
 using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Threading;
+using Jellyfin.Api.Constants;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.LiveTv;
-using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
 
-namespace MediaBrowser.Api
+namespace Jellyfin.Api.Controllers
 {
-    [Route("/Items/{ItemId}", "POST", Summary = "Updates an item")]
-    public class UpdateItem : BaseItemDto, IReturnVoid
-    {
-        [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string ItemId { get; set; }
-    }
-
-    [Route("/Items/{ItemId}/MetadataEditor", "GET", Summary = "Gets metadata editor info for an item")]
-    public class GetMetadataEditorInfo : IReturn<MetadataEditorInfo>
-    {
-        [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string ItemId { get; set; }
-    }
-
-    [Route("/Items/{ItemId}/ContentType", "POST", Summary = "Updates an item's content type")]
-    public class UpdateItemContentType : IReturnVoid
-    {
-        [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public Guid ItemId { get; set; }
-
-        [ApiMember(Name = "ContentType", Description = "The content type of the item", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string ContentType { get; set; }
-    }
-
-    [Authenticated(Roles = "admin")]
-    public class ItemUpdateService : BaseApiService
+    /// <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;
-
-        public ItemUpdateService(
-            ILogger<ItemUpdateService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
+        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)
-            : base(logger, serverConfigurationManager, httpResultFactory)
+            ILocalizationManager localizationManager,
+            IServerConfigurationManager serverConfigurationManager)
         {
             _libraryManager = libraryManager;
             _providerManager = providerManager;
             _localizationManager = localizationManager;
             _fileSystem = fileSystem;
+            _serverConfigurationManager = serverConfigurationManager;
         }
 
-        public object Get(GetMetadataEditorInfo request)
+        /// <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 ActionResult UpdateItem([FromRoute] Guid itemId, [FromBody, Required] BaseItemDto request)
         {
-            var item = _libraryManager.GetItemById(request.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 ICollectionFolder) && !(item is UserView) && !(item is AggregateFolder) && !(item is LiveTvChannel) && !(item is IItemByName) &&
-                item.SourceType == SourceType.Library)
-            {
-                var inheritedContentType = _libraryManager.GetInheritedContentType(item);
-                var configuredContentType = _libraryManager.GetConfiguredContentType(item);
-
-                if (string.IsNullOrWhiteSpace(inheritedContentType) || !string.IsNullOrWhiteSpace(configuredContentType))
-                {
-                    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();
-                    }
-                }
-            }
-
-            return ToOptimizedResult(info);
-        }
-
-        public void Post(UpdateItemContentType request)
-        {
-            var item = _libraryManager.GetItemById(request.ItemId);
-            var path = item.ContainingFolderPath;
-
-            var types = ServerConfigurationManager.Configuration.ContentTypes
-                .Where(i => !string.IsNullOrWhiteSpace(i.Name))
-                .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase))
-                .ToList();
-
-            if (!string.IsNullOrWhiteSpace(request.ContentType))
-            {
-                types.Add(new NameValuePair
-                {
-                    Name = path,
-                    Value = request.ContentType
-                });
-            }
-
-            ServerConfigurationManager.Configuration.ContentTypes = types.ToArray();
-            ServerConfigurationManager.SaveConfiguration();
-        }
-
-        private List<NameValuePair> GetContentTypeOptions(bool isForItem)
-        {
-            var list = new List<NameValuePair>();
-
-            if (isForItem)
-            {
-                list.Add(new NameValuePair
-                {
-                    Name = "Inherit",
-                    Value = ""
-                });
-            }
-
-            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)
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
             {
-                list.Add(new NameValuePair
-                {
-                    Name = "Books",
-                    Value = "books"
-                });
+                return NotFound();
             }
 
-            list.Add(new NameValuePair
-            {
-                Name = "HomeVideos",
-                Value = "homevideos"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "MusicVideos",
-                Value = "musicvideos"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "Photos",
-                Value = "photos"
-            });
-
-            if (!isForItem)
-            {
-                list.Add(new NameValuePair
-                {
-                    Name = "MixedContent",
-                    Value = ""
-                });
-            }
-
-            foreach (var val in list)
-            {
-                val.Name = _localizationManager.GetLocalizedString(val.Name);
-            }
-
-            return list;
-        }
-
-        public void Post(UpdateItem request)
-        {
-            var item = _libraryManager.GetItemById(request.ItemId);
-
             var newLockData = request.LockData ?? false;
             var isLockedChanged = item.IsLocked != newLockData;
 
             var series = item as Series;
-            var displayOrderChanged = series != null && !string.Equals(series.DisplayOrder ?? string.Empty, request.DisplayOrder ?? string.Empty, StringComparison.OrdinalIgnoreCase);
+            var displayOrderChanged = series != 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 != null)
             {
-                _libraryManager.UpdatePeople(item, request.People.Select(x => new PersonInfo { Name = x.Name, Role = x.Role, Type = x.Type }).ToList());
+                _libraryManager.UpdatePeople(
+                    item,
+                    request.People.Select(x => new PersonInfo
+                    {
+                        Name = x.Name,
+                        Role = x.Role,
+                        Type = x.Type
+                    }).ToList());
             }
 
             UpdateItem(request, item);
@@ -232,7 +117,7 @@ namespace MediaBrowser.Api
             if (displayOrderChanged)
             {
                 _providerManager.QueueRefresh(
-                    series.Id,
+                    series!.Id,
                     new MetadataRefreshOptions(new DirectoryService(_fileSystem))
                     {
                         MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
@@ -241,11 +126,101 @@ namespace MediaBrowser.Api
                     },
                     RefreshPriority.High);
             }
+
+            return NoContent();
         }
 
-        private DateTime NormalizeDateTime(DateTime val)
+        /// <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] Guid itemId)
         {
-            return DateTime.SpecifyKind(val, DateTimeKind.Utc);
+            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 ICollectionFolder)
+                && !(item is UserView)
+                && !(item is AggregateFolder)
+                && !(item is LiveTvChannel)
+                && !(item is IItemByName)
+                && item.SourceType == SourceType.Library)
+            {
+                var inheritedContentType = _libraryManager.GetInheritedContentType(item);
+                var configuredContentType = _libraryManager.GetConfiguredContentType(item);
+
+                if (string.IsNullOrWhiteSpace(inheritedContentType) ||
+                    !string.IsNullOrWhiteSpace(configuredContentType))
+                {
+                    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();
+                    }
+                }
+            }
+
+            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] Guid itemId, [FromQuery, Required] string? contentType)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            var path = item.ContainingFolderPath;
+
+            var types = _serverConfigurationManager.Configuration.ContentTypes
+                .Where(i => !string.IsNullOrWhiteSpace(i.Name))
+                .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase))
+                .ToList();
+
+            if (!string.IsNullOrWhiteSpace(contentType))
+            {
+                types.Add(new NameValuePair
+                {
+                    Name = path,
+                    Value = contentType
+                });
+            }
+
+            _serverConfigurationManager.Configuration.ContentTypes = types.ToArray();
+            _serverConfigurationManager.SaveConfiguration();
+            return NoContent();
         }
 
         private void UpdateItem(BaseItemDto request, BaseItem item)
@@ -361,24 +336,25 @@ namespace MediaBrowser.Api
                 }
             }
 
-            if (item is Audio song)
-            {
-                song.Album = request.Album;
-            }
-
-            if (item is MusicVideo musicVideo)
+            switch (item)
             {
-                musicVideo.Album = request.Album;
-            }
+                case Audio song:
+                    song.Album = request.Album;
+                    break;
+                case MusicVideo musicVideo:
+                    musicVideo.Album = request.Album;
+                    break;
+                case Series series:
+                {
+                    series.Status = GetSeriesStatus(request);
 
-            if (item is Series series)
-            {
-                series.Status = GetSeriesStatus(request);
+                    if (request.AirDays != null)
+                    {
+                        series.AirDays = request.AirDays;
+                        series.AirTime = request.AirTime;
+                    }
 
-                if (request.AirDays != null)
-                {
-                    series.AirDays = request.AirDays;
-                    series.AirTime = request.AirTime;
+                    break;
                 }
             }
         }
@@ -392,5 +368,81 @@ namespace MediaBrowser.Api
 
             return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true);
         }
+
+        private DateTime NormalizeDateTime(DateTime val)
+        {
+            return DateTime.SpecifyKind(val, DateTimeKind.Utc);
+        }
+
+        private List<NameValuePair> GetContentTypeOptions(bool isForItem)
+        {
+            var list = new List<NameValuePair>();
+
+            if (isForItem)
+            {
+                list.Add(new NameValuePair
+                {
+                    Name = "Inherit",
+                    Value = string.Empty
+                });
+            }
+
+            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 = "Books",
+                    Value = "books"
+                });
+            }
+
+            list.Add(new NameValuePair
+            {
+                Name = "HomeVideos",
+                Value = "homevideos"
+            });
+            list.Add(new NameValuePair
+            {
+                Name = "MusicVideos",
+                Value = "musicvideos"
+            });
+            list.Add(new NameValuePair
+            {
+                Name = "Photos",
+                Value = "photos"
+            });
+
+            if (!isForItem)
+            {
+                list.Add(new NameValuePair
+                {
+                    Name = "MixedContent",
+                    Value = string.Empty
+                });
+            }
+
+            foreach (var val in list)
+            {
+                val.Name = _localizationManager.GetLocalizedString(val.Name);
+            }
+
+            return list;
+        }
     }
 }

+ 593 - 0
Jellyfin.Api/Controllers/ItemsController.cs

@@ -0,0 +1,593 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+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;
+
+        /// <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>
+        public ItemsController(
+            IUserManager userManager,
+            ILibraryManager libraryManager,
+            ILocalizationManager localization,
+            IDtoService dtoService,
+            ILogger<ItemsController> logger)
+        {
+            _userManager = userManager;
+            _libraryManager = libraryManager;
+            _localization = localization;
+            _dtoService = dtoService;
+            _logger = logger;
+        }
+
+        /// <summary>
+        /// Gets items based on a query.
+        /// </summary>
+        /// <param name="uId">The user id supplied in the /Users/{uid}/Items.</param>
+        /// <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 delimeted.</param>
+        /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</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="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</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 delimeted. 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 delimeted.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
+        /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. 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 delimeted. 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 delimeted.</param>
+        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
+        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
+        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</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 delimeted.</param>
+        /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
+        /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</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 delimeted.</param>
+        /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</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 delimeted.</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 delimeted.</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 delimeted.</param>
+        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</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")]
+        [HttpGet("Users/{uId}/Items", Name = "GetItems_2")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetItems(
+            [FromRoute] Guid? uId,
+            [FromQuery] Guid? userId,
+            [FromQuery] string? maxOfficialRating,
+            [FromQuery] bool? hasThemeSong,
+            [FromQuery] bool? hasThemeVideo,
+            [FromQuery] bool? hasSubtitles,
+            [FromQuery] bool? hasSpecialFeature,
+            [FromQuery] bool? hasTrailer,
+            [FromQuery] string? adjacentTo,
+            [FromQuery] int? parentIndexNumber,
+            [FromQuery] bool? hasParentalRating,
+            [FromQuery] bool? isHd,
+            [FromQuery] bool? is4K,
+            [FromQuery] string? locationTypes,
+            [FromQuery] string? 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] string? excludeItemIds,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] bool? recursive,
+            [FromQuery] string? searchTerm,
+            [FromQuery] string? sortOrder,
+            [FromQuery] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? imageTypes,
+            [FromQuery] string? sortBy,
+            [FromQuery] bool? isPlayed,
+            [FromQuery] string? genres,
+            [FromQuery] string? officialRatings,
+            [FromQuery] string? tags,
+            [FromQuery] string? years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] string? person,
+            [FromQuery] string? personIds,
+            [FromQuery] string? personTypes,
+            [FromQuery] string? studios,
+            [FromQuery] string? artists,
+            [FromQuery] string? excludeArtistIds,
+            [FromQuery] string? artistIds,
+            [FromQuery] string? albumArtistIds,
+            [FromQuery] string? contributingArtistIds,
+            [FromQuery] string? albums,
+            [FromQuery] string? albumIds,
+            [FromQuery] string? ids,
+            [FromQuery] string? 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] string? seriesStatus,
+            [FromQuery] string? nameStartsWithOrGreater,
+            [FromQuery] string? nameStartsWith,
+            [FromQuery] string? nameLessThan,
+            [FromQuery] string? studioIds,
+            [FromQuery] string? genreIds,
+            [FromQuery] bool enableTotalRecordCount = true,
+            [FromQuery] bool? enableImages = true)
+        {
+            // use user id route parameter over query parameter
+            userId = uId ?? userId;
+
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            if (string.Equals(includeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase))
+            {
+                parentId = null;
+            }
+
+            BaseItem? item = null;
+            QueryResult<BaseItem> result;
+            if (!string.IsNullOrEmpty(parentId))
+            {
+                item = _libraryManager.GetItemById(parentId);
+            }
+
+            item ??= _libraryManager.GetUserRootFolder();
+
+            if (!(item is Folder folder))
+            {
+                folder = _libraryManager.GetUserRootFolder();
+            }
+
+            if (folder is IHasCollectionType hasCollectionType
+                && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
+            {
+                recursive = true;
+                includeItemTypes = "Playlist";
+            }
+
+            bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id)
+                                     // Assume all folders inside an EnabledChannel are enabled
+                                     || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.Id);
+
+            var collectionFolders = _libraryManager.GetCollectionFolders(item);
+            foreach (var collectionFolder in collectionFolders)
+            {
+                if (user.GetPreference(PreferenceKind.EnabledFolders).Contains(
+                    collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture),
+                    StringComparer.OrdinalIgnoreCase))
+                {
+                    isInEnabledFolder = true;
+                }
+            }
+
+            if (!(item is UserRootFolder)
+                && !isInEnabledFolder
+                && !user.HasPermission(PermissionKind.EnableAllFolders)
+                && !user.HasPermission(PermissionKind.EnableAllChannels))
+            {
+                _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) || !string.IsNullOrEmpty(ids) || !(item is UserRootFolder))
+            {
+                var query = new InternalItemsQuery(user!)
+                {
+                    IsPlayed = isPlayed,
+                    MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+                    IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                    ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+                    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,
+                    HasOverview = hasOverview,
+                    HasOfficialRating = hasOfficialRating,
+                    HasParentalRating = hasParentalRating,
+                    HasSpecialFeature = hasSpecialFeature,
+                    HasSubtitles = hasSubtitles,
+                    HasThemeSong = hasThemeSong,
+                    HasThemeVideo = hasThemeVideo,
+                    HasTrailer = hasTrailer,
+                    IsHD = isHd,
+                    Is4K = is4K,
+                    Tags = RequestHelpers.Split(tags, '|', true),
+                    OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
+                    Genres = RequestHelpers.Split(genres, '|', true),
+                    ArtistIds = RequestHelpers.GetGuids(artistIds),
+                    AlbumArtistIds = RequestHelpers.GetGuids(albumArtistIds),
+                    ContributingArtistIds = RequestHelpers.GetGuids(contributingArtistIds),
+                    GenreIds = RequestHelpers.GetGuids(genreIds),
+                    StudioIds = RequestHelpers.GetGuids(studioIds),
+                    Person = person,
+                    PersonIds = RequestHelpers.GetGuids(personIds),
+                    PersonTypes = RequestHelpers.Split(personTypes, ',', true),
+                    Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+                    ImageTypes = RequestHelpers.Split(imageTypes, ',', true).Select(v => Enum.Parse<ImageType>(v, true)).ToArray(),
+                    VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(),
+                    AdjacentTo = adjacentTo,
+                    ItemIds = RequestHelpers.GetGuids(ids),
+                    MinCommunityRating = minCommunityRating,
+                    MinCriticRating = minCriticRating,
+                    ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId),
+                    ParentIndexNumber = parentIndexNumber,
+                    EnableTotalRecordCount = enableTotalRecordCount,
+                    ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds),
+                    DtoOptions = dtoOptions,
+                    SearchTerm = searchTerm,
+                    MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
+                    MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
+                    MinPremiereDate = minPremiereDate?.ToUniversalTime(),
+                    MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
+                };
+
+                if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm))
+                {
+                    query.CollapseBoxSetItems = false;
+                }
+
+                foreach (var filter in RequestHelpers.GetFilters(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;
+                    }
+                }
+
+                // Filter by Series Status
+                if (!string.IsNullOrEmpty(seriesStatus))
+                {
+                    query.SeriesStatuses = seriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray();
+                }
+
+                // ExcludeLocationTypes
+                if (!string.IsNullOrEmpty(excludeLocationTypes))
+                {
+                    if (excludeLocationTypes.Split(',').Select(d => (LocationType)Enum.Parse(typeof(LocationType), d, true)).ToArray().Contains(LocationType.Virtual))
+                    {
+                        query.IsVirtualItem = false;
+                    }
+                }
+
+                if (!string.IsNullOrEmpty(locationTypes))
+                {
+                    var requestedLocationTypes = locationTypes.Split(',');
+                    if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4)
+                    {
+                        query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString());
+                    }
+                }
+
+                // 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 (!string.IsNullOrEmpty(artists))
+                {
+                    query.ArtistIds = artists.Split('|').Select(i =>
+                    {
+                        try
+                        {
+                            return _libraryManager.GetArtist(i, new DtoOptions(false));
+                        }
+                        catch
+                        {
+                            return null;
+                        }
+                    }).Where(i => i != null).Select(i => i!.Id).ToArray();
+                }
+
+                // ExcludeArtistIds
+                if (!string.IsNullOrWhiteSpace(excludeArtistIds))
+                {
+                    query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
+                }
+
+                if (!string.IsNullOrWhiteSpace(albumIds))
+                {
+                    query.AlbumIds = RequestHelpers.GetGuids(albumIds);
+                }
+
+                // Albums
+                if (!string.IsNullOrEmpty(albums))
+                {
+                    query.AlbumIds = albums.Split('|').SelectMany(i =>
+                    {
+                        return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 });
+                    }).ToArray();
+                }
+
+                // Studios
+                if (!string.IsNullOrEmpty(studios))
+                {
+                    query.StudioIds = studios.Split('|').Select(i =>
+                    {
+                        try
+                        {
+                            return _libraryManager.GetStudio(i);
+                        }
+                        catch
+                        {
+                            return null;
+                        }
+                    }).Where(i => i != 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 && string.Equals(query.IncludeItemTypes[0], "MusicAlbum", StringComparison.OrdinalIgnoreCase))
+                    {
+                        query.OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.ProductionYear, SortOrder.Descending), new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) };
+                    }
+                }
+
+                result = folder.GetItems(query);
+            }
+            else
+            {
+                var itemsArray = folder.GetChildren(user, true);
+                result = new QueryResult<BaseItem> { Items = itemsArray, TotalRecordCount = itemsArray.Count, StartIndex = 0 };
+            }
+
+            return new QueryResult<BaseItemDto> { StartIndex = startIndex.GetValueOrDefault(), TotalRecordCount = result.TotalRecordCount, Items = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user) };
+        }
+
+        /// <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 delimeted. 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 delimeted.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
+        /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+        /// <param name="enableImages">Optional. Include image information in output.</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] Guid userId,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? searchTerm,
+            [FromQuery] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] bool enableTotalRecordCount = true,
+            [FromQuery] bool? enableImages = true)
+        {
+            var user = _userManager.GetUserById(userId);
+            var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            var ancestorIds = Array.Empty<Guid>();
+
+            var excludeFolderIds = user.GetPreference(PreferenceKind.LatestItemExcludes);
+            if (parentIdGuid.Equals(Guid.Empty) && excludeFolderIds.Length > 0)
+            {
+                ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true)
+                    .Where(i => i is Folder)
+                    .Where(i => !excludeFolderIds.Contains(i.Id.ToString("N", CultureInfo.InvariantCulture)))
+                    .Select(i => i.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 = RequestHelpers.Split(mediaTypes, ',', true),
+                IsVirtualItem = false,
+                CollapseBoxSetItems = false,
+                EnableTotalRecordCount = enableTotalRecordCount,
+                AncestorIds = ancestorIds,
+                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+                SearchTerm = searchTerm
+            });
+
+            var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, dtoOptions, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                StartIndex = startIndex.GetValueOrDefault(),
+                TotalRecordCount = itemsResult.TotalRecordCount,
+                Items = returnItems
+            };
+        }
+    }
+}

+ 1036 - 0
Jellyfin.Api/Controllers/LibraryController.cs

@@ -0,0 +1,1036 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.LibraryDtos;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using Book = MediaBrowser.Controller.Entities.Book;
+using Movie = Jellyfin.Data.Entities.Movie;
+using MusicAlbum = Jellyfin.Data.Entities.MusicAlbum;
+
+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 IAuthorizationContext _authContext;
+        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="authContext">Instance of the <see cref="IAuthorizationContext"/> 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,
+            IAuthorizationContext authContext,
+            IActivityManager activityManager,
+            ILocalizationManager localization,
+            ILibraryMonitor libraryMonitor,
+            ILogger<LibraryController> logger,
+            IServerConfigurationManager serverConfigurationManager)
+        {
+            _providerManager = providerManager;
+            _libraryManager = libraryManager;
+            _userManager = userManager;
+            _dtoService = dtoService;
+            _authContext = authContext;
+            _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)]
+        public ActionResult GetFile([FromRoute] Guid itemId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            using var fileStream = new FileStream(item.Path, FileMode.Open, FileAccess.Read);
+            return File(fileStream, MimeTypes.GetMimeType(item.Path));
+        }
+
+        /// <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>();
+        }
+
+        /// <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] Guid itemId,
+            [FromQuery] Guid? userId,
+            [FromQuery] bool inheritFromParent = false)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            var item = itemId.Equals(Guid.Empty)
+                ? (!userId.Equals(Guid.Empty)
+                    ? _libraryManager.GetUserRootFolder()
+                    : _libraryManager.RootFolder)
+                : _libraryManager.GetItemById(itemId);
+
+            if (item == null)
+            {
+                return NotFound("Item not found.");
+            }
+
+            IEnumerable<BaseItem> themeItems;
+
+            while (true)
+            {
+                themeItems = item.GetThemeSongs();
+
+                if (themeItems.Any() || !inheritFromParent)
+                {
+                    break;
+                }
+
+                var parent = item.GetParent();
+                if (parent == null)
+                {
+                    break;
+                }
+
+                item = parent;
+            }
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            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 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] Guid itemId,
+            [FromQuery] Guid? userId,
+            [FromQuery] bool inheritFromParent = false)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            var item = itemId.Equals(Guid.Empty)
+                ? (!userId.Equals(Guid.Empty)
+                    ? _libraryManager.GetUserRootFolder()
+                    : _libraryManager.RootFolder)
+                : _libraryManager.GetItemById(itemId);
+
+            if (item == null)
+            {
+                return NotFound("Item not found.");
+            }
+
+            IEnumerable<BaseItem> themeItems;
+
+            while (true)
+            {
+                themeItems = item.GetThemeVideos();
+
+                if (themeItems.Any() || !inheritFromParent)
+                {
+                    break;
+                }
+
+                var parent = item.GetParent();
+                if (parent == null)
+                {
+                    break;
+                }
+
+                item = parent;
+            }
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            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] 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>
+        [HttpGet("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 auth = _authContext.GetAuthorizationInfo(Request);
+            var user = auth.User;
+
+            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] string? ids)
+        {
+            if (string.IsNullOrEmpty(ids))
+            {
+                return NoContent();
+            }
+
+            var itemIds = RequestHelpers.Split(ids, ',', true);
+            foreach (var i in itemIds)
+            {
+                var item = _libraryManager.GetItemById(i);
+                var auth = _authContext.GetAuthorizationInfo(Request);
+                var user = auth.User;
+
+                if (!item.CanDelete(user))
+                {
+                    if (ids.Length > 1)
+                    {
+                        return Unauthorized("Unauthorized access");
+                    }
+
+                    continue;
+                }
+
+                _libraryManager.DeleteItem(
+                    item,
+                    new DeleteOptions { DeleteFileLocation = true },
+                    true);
+            }
+
+            return NoContent();
+        }
+
+        /// <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.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            var counts = new ItemCounts
+            {
+                AlbumCount = GetCount(typeof(MusicAlbum), user, isFavorite),
+                EpisodeCount = GetCount(typeof(Episode), user, isFavorite),
+                MovieCount = GetCount(typeof(Movie), user, isFavorite),
+                SeriesCount = GetCount(typeof(Series), user, isFavorite),
+                SongCount = GetCount(typeof(Audio), user, isFavorite),
+                MusicVideoCount = GetCount(typeof(MusicVideo), user, isFavorite),
+                BoxSetCount = GetCount(typeof(BoxSet), user, isFavorite),
+                BookCount = GetCount(typeof(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] Guid itemId, [FromQuery] Guid? userId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+
+            if (item == null)
+            {
+                return NotFound("Item not found");
+            }
+
+            var baseItemDtos = new List<BaseItemDto>();
+
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            BaseItem parent = item.GetParent();
+
+            while (parent != null)
+            {
+                if (user != null)
+                {
+                    parent = TranslateParentItem(parent, user);
+                }
+
+                baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user));
+
+                parent = parent.GetParent();
+            }
+
+            return baseItemDtos;
+        }
+
+        /// <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));
+        }
+
+        /// <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.DefaultAuthorization)]
+        [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();
+
+            if (isHidden.HasValue)
+            {
+                var val = isHidden.Value;
+
+                items = items.Where(i => i.IsHidden == val).ToList();
+            }
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var result = new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = items.Count,
+                Items = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions)).ToArray()
+            };
+
+            return result;
+        }
+
+        /// <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
+            {
+                IncludeItemTypes = new[] { nameof(Series) },
+                DtoOptions = new DtoOptions(false)
+                {
+                    EnableImages = false
+                }
+            }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray();
+
+            foreach (var item in series)
+            {
+                _libraryMonitor.ReportFileSystemChanged(item.Path);
+            }
+
+            return NoContent();
+        }
+
+        /// <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([FromRoute] string? tmdbId, [FromRoute] string? imdbId)
+        {
+            var movies = _libraryManager.GetItemList(new InternalItemsQuery
+            {
+                IncludeItemTypes = new[] { nameof(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>();
+            }
+
+            foreach (var item in movies)
+            {
+                _libraryMonitor.ReportFileSystemChanged(item.Path);
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports that new movies have been added by an external source.
+        /// </summary>
+        /// <param name="updates">A list of updated media 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[] updates)
+        {
+            foreach (var item in updates)
+            {
+                _libraryMonitor.ReportFileSystemChanged(item.Path);
+            }
+
+            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)]
+        public ActionResult GetDownload([FromRoute] Guid itemId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            var auth = _authContext.GetAuthorizationInfo(Request);
+
+            var user = auth.User;
+
+            if (user != 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");
+                }
+            }
+
+            if (user != null)
+            {
+                LogDownload(item, user, auth);
+            }
+
+            var path = item.Path;
+
+            // Quotes are valid in linux. They'll possibly cause issues here
+            var filename = (Path.GetFileName(path) ?? string.Empty).Replace("\"", string.Empty, StringComparison.Ordinal);
+            if (!string.IsNullOrWhiteSpace(filename))
+            {
+                // Kestrel doesn't support non-ASCII characters in headers
+                if (Regex.IsMatch(filename, @"[^\p{IsBasicLatin}]"))
+                {
+                    // Manually encoding non-ASCII characters, following https://tools.ietf.org/html/rfc5987#section-3.2.2
+                    filename = WebUtility.UrlEncode(filename);
+                }
+            }
+
+            // TODO determine non-ASCII validity.
+            return PhysicalFile(path, MimeTypes.GetMimeType(path));
+        }
+
+        /// <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 = "GetSimilarArtists2")]
+        [HttpGet("Items/{itemId}/Similar")]
+        [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums2")]
+        [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows2")]
+        [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies2")]
+        [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers2")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
+            [FromRoute] Guid itemId,
+            [FromQuery] string? excludeArtistIds,
+            [FromQuery] Guid? userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields)
+        {
+            var item = itemId.Equals(Guid.Empty)
+                ? (!userId.Equals(Guid.Empty)
+                    ? _libraryManager.GetUserRootFolder()
+                    : _libraryManager.RootFolder)
+                : _libraryManager.GetItemById(itemId);
+
+            var program = item as IHasProgramAttributes;
+            var isMovie = item is MediaBrowser.Controller.Entities.Movies.Movie || (program != null && program.IsMovie) || item is Trailer;
+            if (program != null && program.IsSeries)
+            {
+                return GetSimilarItemsResult(
+                    item,
+                    excludeArtistIds,
+                    userId,
+                    limit,
+                    fields,
+                    new[] { nameof(Series) },
+                    false);
+            }
+
+            if (item is MediaBrowser.Controller.Entities.TV.Episode || (item is IItemByName && !(item is MusicArtist)))
+            {
+                return new QueryResult<BaseItemDto>();
+            }
+
+            return GetSimilarItemsResult(
+                item,
+                excludeArtistIds,
+                userId,
+                limit,
+                fields,
+                new[] { item.GetType().Name },
+                isMovie);
+        }
+
+        /// <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)
+        {
+            var result = new LibraryOptionsResultDto();
+
+            var types = GetRepresentativeItemTypes(libraryContentType);
+            var typesList = types.ToList();
+
+            var plugins = _providerManager.GetAllMetadataPlugins()
+                .Where(i => types.Contains(i.ItemType, StringComparer.OrdinalIgnoreCase))
+                .OrderBy(i => typesList.IndexOf(i.ItemType))
+                .ToList();
+
+            result.MetadataSavers = plugins
+                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver))
+                .Select(i => new LibraryOptionInfoDto
+                {
+                    Name = i.Name,
+                    DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary)
+                })
+                .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+                .Select(x => x.First())
+                .ToArray();
+
+            result.MetadataReaders = plugins
+                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider))
+                .Select(i => new LibraryOptionInfoDto
+                {
+                    Name = i.Name,
+                    DefaultEnabled = true
+                })
+                .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+                .Select(x => x.First())
+                .ToArray();
+
+            result.SubtitleFetchers = plugins
+                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher))
+                .Select(i => new LibraryOptionInfoDto
+                {
+                    Name = i.Name,
+                    DefaultEnabled = true
+                })
+                .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+                .Select(x => x.First())
+                .ToArray();
+
+            var typeOptions = new List<LibraryTypeOptionsDto>();
+
+            foreach (var type in types)
+            {
+                TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions);
+
+                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)
+                    })
+                    .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+                    .Select(x => x.First())
+                    .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)
+                    })
+                    .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+                    .Select(x => x.First())
+                    .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>()
+                });
+            }
+
+            result.TypeOptions = typeOptions.ToArray();
+
+            return result;
+        }
+
+        private int GetCount(Type type, User? user, bool? isFavorite)
+        {
+            var query = new InternalItemsQuery(user)
+            {
+                IncludeItemTypes = new[] { type.Name },
+                Limit = 0,
+                Recursive = true,
+                IsVirtualItem = false,
+                IsFavorite = isFavorite,
+                DtoOptions = new DtoOptions(false)
+                {
+                    EnableImages = false
+                }
+            };
+
+            return _libraryManager.GetItemsResult(query).TotalRecordCount;
+        }
+
+        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 void LogDownload(BaseItem item, User user, AuthorizationInfo auth)
+        {
+            try
+            {
+                _activityManager.Create(new ActivityLog(
+                    string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name),
+                    "UserDownloadingContent",
+                    auth.UserId)
+                {
+                    ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), auth.Client, auth.Device),
+                });
+            }
+            catch
+            {
+                // Logged at lower levels
+            }
+        }
+
+        private QueryResult<BaseItemDto> GetSimilarItemsResult(
+            BaseItem item,
+            string? excludeArtistIds,
+            Guid? userId,
+            int? limit,
+            string? fields,
+            string[] includeItemTypes,
+            bool isMovie)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request);
+
+            var query = new InternalItemsQuery(user)
+            {
+                Limit = limit,
+                IncludeItemTypes = includeItemTypes,
+                IsMovie = isMovie,
+                SimilarTo = item,
+                DtoOptions = dtoOptions,
+                EnableTotalRecordCount = !isMovie,
+                EnableGroupByMetadataKey = isMovie
+            };
+
+            // ExcludeArtistIds
+            if (!string.IsNullOrEmpty(excludeArtistIds))
+            {
+                query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
+            }
+
+            List<BaseItem> itemsResult;
+
+            if (isMovie)
+            {
+                var itemTypes = new List<string> { nameof(MediaBrowser.Controller.Entities.Movies.Movie) };
+                if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+                {
+                    itemTypes.Add(nameof(Trailer));
+                    itemTypes.Add(nameof(LiveTvProgram));
+                }
+
+                query.IncludeItemTypes = itemTypes.ToArray();
+                itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList();
+            }
+            else if (item is MusicArtist)
+            {
+                query.IncludeItemTypes = Array.Empty<string>();
+
+                itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList();
+            }
+            else
+            {
+                itemsResult = _libraryManager.GetItemList(query);
+            }
+
+            var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user);
+
+            var result = new QueryResult<BaseItemDto>
+            {
+                Items = returnList,
+                TotalRecordCount = itemsResult.Count
+            };
+
+            return result;
+        }
+
+        private static string[] GetRepresentativeItemTypes(string? contentType)
+        {
+            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;
+            }
+
+            var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions
+                .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparer.OrdinalIgnoreCase))
+                .ToArray();
+
+            return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparer.OrdinalIgnoreCase));
+        }
+
+        private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary)
+        {
+            if (isNewLibrary)
+            {
+                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);
+            }
+
+            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, StringComparer.OrdinalIgnoreCase));
+        }
+
+        private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary)
+        {
+            if (isNewLibrary)
+            {
+                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);
+            }
+
+            var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions
+                .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+                .ToArray();
+
+            if (metadataOptions.Length == 0)
+            {
+                return true;
+            }
+
+            return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase));
+        }
+    }
+}

+ 331 - 0
Jellyfin.Api/Controllers/LibraryStructureController.cs

@@ -0,0 +1,331 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.LibraryStructureDto;
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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;
+        }
+
+        /// <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] string? collectionType,
+            [FromQuery] string[] paths,
+            [FromBody] LibraryOptionsDto? libraryOptionsDto,
+            [FromQuery] bool refreshLibrary = false)
+        {
+            var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions();
+
+            if (paths != null && paths.Length > 0)
+            {
+                libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo { Path = i }).ToArray();
+            }
+
+            await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false);
+
+            return NoContent();
+        }
+
+        /// <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();
+        }
+
+        /// <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));
+            }
+
+            if (string.IsNullOrWhiteSpace(newName))
+            {
+                throw new ArgumentNullException(nameof(newName));
+            }
+
+            var rootFolderPath = _appPaths.DefaultUserViewsPath;
+
+            var currentPath = Path.Combine(rootFolderPath, name);
+            var newPath = Path.Combine(rootFolderPath, newName);
+
+            if (!Directory.Exists(currentPath))
+            {
+                return NotFound("The media collection does not exist.");
+            }
+
+            if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath))
+            {
+                return Conflict($"The media library already exists at {newPath}.");
+            }
+
+            _libraryMonitor.Stop();
+
+            try
+            {
+                // Changing capitalization. Handle windows case insensitivity
+                if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase))
+                {
+                    var tempPath = Path.Combine(
+                        rootFolderPath,
+                        Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
+                    Directory.Move(currentPath, tempPath);
+                    currentPath = tempPath;
+                }
+
+                Directory.Move(currentPath, newPath);
+            }
+            finally
+            {
+                CollectionFolder.OnCollectionFolderChange();
+
+                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();
+                    }
+                });
+            }
+
+            return NoContent();
+        }
+
+        /// <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();
+
+            try
+            {
+                var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo { Path = mediaPathDto.Path };
+
+                _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();
+                    }
+                });
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Updates a media path.
+        /// </summary>
+        /// <param name="name">The name of the library.</param>
+        /// <param name="pathInfo">The path info.</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(
+            [FromQuery] string? name,
+            [FromBody] MediaPathInfo? pathInfo)
+        {
+            if (string.IsNullOrWhiteSpace(name))
+            {
+                throw new ArgumentNullException(nameof(name));
+            }
+
+            _libraryManager.UpdateMediaPath(name, 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))
+            {
+                throw new ArgumentNullException(nameof(name));
+            }
+
+            _libraryMonitor.Stop();
+
+            try
+            {
+                _libraryManager.RemoveMediaPath(name, path);
+            }
+            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();
+                    }
+                });
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Update library options.
+        /// </summary>
+        /// <param name="id">The library name.</param>
+        /// <param name="libraryOptions">The library options.</param>
+        /// <response code="204">Library updated.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("LibraryOptions")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult UpdateLibraryOptions(
+            [FromQuery] string? id,
+            [FromBody] LibraryOptions? libraryOptions)
+        {
+            var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(id);
+
+            collectionFolder.UpdateLibraryOptions(libraryOptions);
+            return NoContent();
+        }
+    }
+}

+ 1238 - 0
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -0,0 +1,1238 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.LiveTvDtos;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Live tv controller.
+    /// </summary>
+    public class LiveTvController : BaseJellyfinApiController
+    {
+        private readonly ILiveTvManager _liveTvManager;
+        private readonly IUserManager _userManager;
+        private readonly IHttpClient _httpClient;
+        private readonly ILibraryManager _libraryManager;
+        private readonly IDtoService _dtoService;
+        private readonly ISessionContext _sessionContext;
+        private readonly IMediaSourceManager _mediaSourceManager;
+        private readonly IConfigurationManager _configurationManager;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+
+        /// <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="httpClient">Instance of the <see cref="IHttpClient"/> 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="sessionContext">Instance of the <see cref="ISessionContext"/> 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>
+        public LiveTvController(
+            ILiveTvManager liveTvManager,
+            IUserManager userManager,
+            IHttpClient httpClient,
+            ILibraryManager libraryManager,
+            IDtoService dtoService,
+            ISessionContext sessionContext,
+            IMediaSourceManager mediaSourceManager,
+            IConfigurationManager configurationManager,
+            TranscodingJobHelper transcodingJobHelper)
+        {
+            _liveTvManager = liveTvManager;
+            _userManager = userManager;
+            _httpClient = httpClient;
+            _libraryManager = libraryManager;
+            _dtoService = dtoService;
+            _sessionContext = sessionContext;
+            _mediaSourceManager = mediaSourceManager;
+            _configurationManager = configurationManager;
+            _transcodingJobHelper = transcodingJobHelper;
+        }
+
+        /// <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. 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="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] string? enableImageTypes,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] string? sortBy,
+            [FromQuery] SortOrder? sortOrder,
+            [FromQuery] bool enableFavoriteSorting = false,
+            [FromQuery] bool addCurrentProgram = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .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 = RequestHelpers.Split(sortBy, ',', true),
+                    SortOrder = sortOrder ?? SortOrder.Ascending,
+                    AddCurrentProgram = addCurrentProgram
+                },
+                dtoOptions,
+                CancellationToken.None);
+
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            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>
+            {
+                Items = returnArray,
+                TotalRecordCount = channelResult.TotalRecordCount
+            };
+        }
+
+        /// <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] Guid channelId, [FromQuery] Guid? userId)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var item = channelId.Equals(Guid.Empty)
+                ? _libraryManager.GetUserRootFolder()
+                : _libraryManager.GetItemById(channelId);
+
+            var dtoOptions = new DtoOptions()
+                .AddClientFields(Request);
+            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+        }
+
+        /// <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. 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="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] string? enableImageTypes,
+            [FromQuery] string? 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()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .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 = RequestHelpers.GetItemFields(fields),
+                ImageTypeLimit = imageTypeLimit,
+                EnableImages = enableImages
+            }, dtoOptions);
+        }
+
+        /// <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. 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="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] string? enableImageTypes,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            return new QueryResult<BaseItemDto>();
+        }
+
+        /// <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>();
+        }
+
+        /// <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.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var folders = _liveTvManager.GetRecordingFolders(user);
+
+            var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = returnArray,
+                TotalRecordCount = returnArray.Count
+            };
+        }
+
+        /// <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] Guid recordingId, [FromQuery] Guid? userId)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var item = recordingId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId);
+
+            var dtoOptions = new DtoOptions()
+                .AddClientFields(Request);
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+        }
+
+        /// <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 ActionResult ResetTuner([FromRoute] string tunerId)
+        {
+            AssertUserCanManageLiveTv();
+            _liveTvManager.ResetTuner(tunerId, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <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(string timerId)
+        {
+            return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false);
+        }
+
+        /// <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>
+        /// 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 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. 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="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] string? 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] string? sortBy,
+            [FromQuery] string? sortOrder,
+            [FromQuery] string? genres,
+            [FromQuery] string? genreIds,
+            [FromQuery] bool? enableImages,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] string? seriesTimerId,
+            [FromQuery] Guid? librarySeriesId,
+            [FromQuery] string? fields,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            var query = new InternalItemsQuery(user)
+            {
+                ChannelIds = RequestHelpers.Split(channelIds, ',', true)
+                    .Select(i => new Guid(i)).ToArray(),
+                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 = RequestHelpers.Split(genres, ',', true),
+                GenreIds = RequestHelpers.GetGuids(genreIds)
+            };
+
+            if (!librarySeriesId.Equals(Guid.Empty))
+            {
+                query.IsSeries = true;
+
+                if (_libraryManager.GetItemById(librarySeriesId ?? Guid.Empty) is Series series)
+                {
+                    query.Name = series.Name;
+                }
+            }
+
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .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(Guid.Empty) ? null : _userManager.GetUserById(body.UserId);
+
+            var query = new InternalItemsQuery(user)
+            {
+                ChannelIds = RequestHelpers.Split(body.ChannelIds, ',', true)
+                    .Select(i => new Guid(i)).ToArray(),
+                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 = RequestHelpers.Split(body.Genres, ',', true),
+                GenreIds = RequestHelpers.GetGuids(body.GenreIds)
+            };
+
+            if (!body.LibrarySeriesId.Equals(Guid.Empty))
+            {
+                query.IsSeries = true;
+
+                if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series)
+                {
+                    query.Name = series.Name;
+                }
+            }
+
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(body.Fields)
+                .AddClientFields(Request)
+                .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. 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="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 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] string? enableImageTypes,
+            [FromQuery] string? genreIds,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            var query = new InternalItemsQuery(user)
+            {
+                IsAiring = isAiring,
+                Limit = limit,
+                HasAired = hasAired,
+                IsSeries = isSeries,
+                IsMovie = isMovie,
+                IsKids = isKids,
+                IsNews = isNews,
+                IsSports = isSports,
+                EnableTotalRecordCount = enableTotalRecordCount,
+                GenreIds = RequestHelpers.GetGuids(genreIds)
+            };
+
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+            return _liveTvManager.GetRecommendedPrograms(query, dtoOptions, CancellationToken.None);
+        }
+
+        /// <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] string programId,
+            [FromQuery] Guid? userId)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false);
+        }
+
+        /// <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 ActionResult DeleteRecording([FromRoute] Guid recordingId)
+        {
+            AssertUserCanManageLiveTv();
+
+            var item = _libraryManager.GetItemById(recordingId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            _libraryManager.DeleteItem(item, new DeleteOptions
+            {
+                DeleteFileLocation = false
+            });
+
+            return NoContent();
+        }
+
+        /// <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] string timerId)
+        {
+            AssertUserCanManageLiveTv();
+            await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <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] string timerId, [FromBody] TimerInfoDto timerInfo)
+        {
+            AssertUserCanManageLiveTv();
+            await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <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)
+        {
+            AssertUserCanManageLiveTv();
+            await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
+            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] string timerId)
+        {
+            var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false);
+            if (timer == null)
+            {
+                return NotFound();
+            }
+
+            return timer;
+        }
+
+        /// <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>
+        /// 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] string timerId)
+        {
+            AssertUserCanManageLiveTv();
+            await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <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] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo)
+        {
+            AssertUserCanManageLiveTv();
+            await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <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)
+        {
+            AssertUserCanManageLiveTv();
+            await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
+            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] Guid? groupId)
+        {
+            return NotFound();
+        }
+
+        /// <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 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>
+        /// 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>
+        /// 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>
+        /// 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>
+        [HttpGet("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)
+        {
+            using var sha = SHA1.Create();
+            if (!string.IsNullOrEmpty(pw))
+            {
+                listingsProviderInfo.Password = Hex.Encode(sha.ComputeHash(Encoding.UTF8.GetBytes(pw)));
+            }
+
+            return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
+        }
+
+        /// <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 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);
+        }
+
+        /// <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)]
+        public async Task<ActionResult> GetSchedulesDirectCountries()
+        {
+            // https://json.schedulesdirect.org/20141201/available/countries
+            var response = await _httpClient.Get(new HttpRequestOptions
+            {
+                Url = "https://json.schedulesdirect.org/20141201/available/countries",
+                BufferContent = false
+            }).ConfigureAwait(false);
+            return File(response, MediaTypeNames.Application.Json);
+        }
+
+        /// <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 listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase));
+
+            var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name;
+
+            var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None)
+                .ConfigureAwait(false);
+
+            var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None)
+                .ConfigureAwait(false);
+
+            var mappings = listingsProviderInfo.ChannelMappings;
+
+            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
+            };
+        }
+
+        /// <summary>
+        /// Set channel mappings.
+        /// </summary>
+        /// <param name="providerId">Provider id.</param>
+        /// <param name="tunerChannelId">Tuner channel id.</param>
+        /// <param name="providerChannelId">Provider channel id.</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(
+            [FromQuery] string? providerId,
+            [FromQuery] string? tunerChannelId,
+            [FromQuery] string? providerChannelId)
+        {
+            return await _liveTvManager.SetChannelMapping(providerId, tunerChannelId, providerChannelId).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();
+        }
+
+        /// <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")]
+        [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 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)]
+        public async Task<ActionResult> GetLiveRecordingFile([FromRoute] string recordingId)
+        {
+            var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId);
+
+            if (string.IsNullOrWhiteSpace(path))
+            {
+                return NotFound();
+            }
+
+            await using var memoryStream = new MemoryStream();
+            await new ProgressiveFileCopier(path, null, _transcodingJobHelper, CancellationToken.None)
+                .WriteToAsync(memoryStream, CancellationToken.None)
+                .ConfigureAwait(false);
+            return File(memoryStream, MimeTypes.GetMimeType(path));
+        }
+
+        /// <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)]
+        public async Task<ActionResult> GetLiveStreamFile([FromRoute] string streamId, [FromRoute] string container)
+        {
+            var liveStreamInfo = await _mediaSourceManager.GetDirectStreamProviderByUniqueId(streamId, CancellationToken.None).ConfigureAwait(false);
+            if (liveStreamInfo == null)
+            {
+                return NotFound();
+            }
+
+            await using var memoryStream = new MemoryStream();
+            await new ProgressiveFileCopier(liveStreamInfo, null, _transcodingJobHelper, CancellationToken.None)
+                .WriteToAsync(memoryStream, CancellationToken.None)
+                .ConfigureAwait(false);
+            return File(memoryStream, MimeTypes.GetMimeType("file." + container));
+        }
+
+        private void AssertUserCanManageLiveTv()
+        {
+            var user = _sessionContext.GetUser(Request);
+
+            if (user == null)
+            {
+                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.");
+            }
+        }
+    }
+}

+ 76 - 0
Jellyfin.Api/Controllers/LocalizationController.cs

@@ -0,0 +1,76 @@
+using System.Collections.Generic;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Localization controller.
+    /// </summary>
+    [Authorize(Policy = Policies.FirstTimeSetupOrDefault)]
+    public class LocalizationController : BaseJellyfinApiController
+    {
+        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;
+        }
+
+        /// <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 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());
+        }
+    }
+}

+ 337 - 239
MediaBrowser.Api/Playback/MediaInfoService.cs → Jellyfin.Api/Controllers/MediaInfoController.cs

@@ -1,14 +1,16 @@
-#pragma warning disable CS1591
-#pragma warning disable SA1402
-#pragma warning disable SA1649
-
-using System;
+using System;
 using System.Buffers;
+using System.ComponentModel.DataAnnotations;
 using System.Globalization;
 using System.Linq;
+using System.Net.Mime;
 using System.Text.Json;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.MediaInfoDtos;
+using Jellyfin.Api.Models.VideoDtos;
+using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Configuration;
@@ -22,54 +24,20 @@ using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.MediaInfo;
-using MediaBrowser.Model.Services;
 using MediaBrowser.Model.Session;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace MediaBrowser.Api.Playback
+namespace Jellyfin.Api.Controllers
 {
-    [Route("/Items/{Id}/PlaybackInfo", "GET", Summary = "Gets live playback media info for an item")]
-    public class GetPlaybackInfo : IReturn<PlaybackInfoResponse>
-    {
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public Guid Id { get; set; }
-
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-    }
-
-    [Route("/Items/{Id}/PlaybackInfo", "POST", Summary = "Gets live playback media info for an item")]
-    public class GetPostedPlaybackInfo : PlaybackInfoRequest, IReturn<PlaybackInfoResponse>
-    {
-    }
-
-    [Route("/LiveStreams/Open", "POST", Summary = "Opens a media source")]
-    public class OpenMediaSource : LiveStreamRequest, IReturn<LiveStreamResponse>
-    {
-    }
-
-    [Route("/LiveStreams/Close", "POST", Summary = "Closes a media source")]
-    public class CloseMediaSource : IReturnVoid
-    {
-        [ApiMember(Name = "LiveStreamId", Description = "LiveStreamId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string LiveStreamId { get; set; }
-    }
-
-    [Route("/Playback/BitrateTest", "GET")]
-    public class GetBitrateTestBytes
-    {
-        [ApiMember(Name = "Size", Description = "Size", IsRequired = true, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int Size { get; set; }
-
-        public GetBitrateTestBytes()
-        {
-            // 100k
-            Size = 102400;
-        }
-    }
-
-    [Authenticated]
-    public class MediaInfoService : BaseApiService
+    /// <summary>
+    /// The media info controller.
+    /// </summary>
+    [Route("")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class MediaInfoController : BaseJellyfinApiController
     {
         private readonly IMediaSourceManager _mediaSourceManager;
         private readonly IDeviceManager _deviceManager;
@@ -78,19 +46,31 @@ namespace MediaBrowser.Api.Playback
         private readonly IMediaEncoder _mediaEncoder;
         private readonly IUserManager _userManager;
         private readonly IAuthorizationContext _authContext;
-
-        public MediaInfoService(
-            ILogger<MediaInfoService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
+        private readonly ILogger<MediaInfoController> _logger;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+
+        /// <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="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        public MediaInfoController(
             IMediaSourceManager mediaSourceManager,
             IDeviceManager deviceManager,
             ILibraryManager libraryManager,
             INetworkManager networkManager,
             IMediaEncoder mediaEncoder,
             IUserManager userManager,
-            IAuthorizationContext authContext)
-            : base(logger, serverConfigurationManager, httpResultFactory)
+            IAuthorizationContext authContext,
+            ILogger<MediaInfoController> logger,
+            IServerConfigurationManager serverConfigurationManager)
         {
             _mediaSourceManager = mediaSourceManager;
             _deviceManager = deviceManager;
@@ -99,56 +79,71 @@ namespace MediaBrowser.Api.Playback
             _mediaEncoder = mediaEncoder;
             _userManager = userManager;
             _authContext = authContext;
+            _logger = logger;
+            _serverConfigurationManager = serverConfigurationManager;
         }
 
-        public object Get(GetBitrateTestBytes request)
-        {
-            const int MaxSize = 10_000_000;
-
-            var size = request.Size;
-
-            if (size <= 0)
-            {
-                throw new ArgumentException($"The requested size ({size}) is equal to or smaller than 0.", nameof(request));
-            }
-
-            if (size > MaxSize)
-            {
-                throw new ArgumentException($"The requested size ({size}) is larger than the max allowed value ({MaxSize}).", nameof(request));
-            }
-
-            byte[] buffer = ArrayPool<byte>.Shared.Rent(size);
-            try
-            {
-                new Random().NextBytes(buffer);
-                return ResultFactory.GetResult(null, buffer, "application/octet-stream");
-            }
-            finally
-            {
-                ArrayPool<byte>.Shared.Return(buffer);
-            }
-        }
-
-        public async Task<object> Get(GetPlaybackInfo request)
+        /// <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] Guid itemId, [FromQuery, Required] Guid? userId)
         {
-            var result = await GetPlaybackInfo(request.Id, request.UserId, new[] { MediaType.Audio, MediaType.Video }).ConfigureAwait(false);
-            return ToOptimizedResult(result);
+            return await GetPlaybackInfoInternal(itemId, userId).ConfigureAwait(false);
         }
 
-        public async Task<object> Post(OpenMediaSource request)
-        {
-            var result = await OpenMediaSource(request).ConfigureAwait(false);
-
-            return ToOptimizedResult(result);
-        }
-
-        private async Task<LiveStreamResponse> OpenMediaSource(OpenMediaSource request)
+        /// <summary>
+        /// Gets live playback media info for an item.
+        /// </summary>
+        /// <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="deviceProfile">The device profile.</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>
+        /// <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] Guid itemId,
+            [FromQuery] Guid? userId,
+            [FromQuery] long? maxStreamingBitrate,
+            [FromQuery] long? startTimeTicks,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] int? maxAudioChannels,
+            [FromQuery] string? mediaSourceId,
+            [FromQuery] string? liveStreamId,
+            [FromBody] DeviceProfileDto? deviceProfile,
+            [FromQuery] bool autoOpenLiveStream = false,
+            [FromQuery] bool enableDirectPlay = true,
+            [FromQuery] bool enableDirectStream = true,
+            [FromQuery] bool enableTranscoding = true,
+            [FromQuery] bool allowVideoStreamCopy = true,
+            [FromQuery] bool allowAudioStreamCopy = true)
         {
             var authInfo = _authContext.GetAuthorizationInfo(Request);
 
-            var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
+            var profile = deviceProfile?.DeviceProfile;
+
+            _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile);
 
-            var profile = request.DeviceProfile;
             if (profile == null)
             {
                 var caps = _deviceManager.GetCapabilities(authInfo.DeviceId);
@@ -158,80 +153,57 @@ namespace MediaBrowser.Api.Playback
                 }
             }
 
-            if (profile != null)
-            {
-                var item = _libraryManager.GetItemById(request.ItemId);
-
-                SetDeviceSpecificData(item, result.MediaSource, profile, authInfo, request.MaxStreamingBitrate,
-                    request.StartTimeTicks ?? 0, result.MediaSource.Id, request.AudioStreamIndex,
-                    request.SubtitleStreamIndex, request.MaxAudioChannels, request.PlaySessionId, request.UserId, request.EnableDirectPlay, true, request.EnableDirectStream, true, true, true);
-            }
-            else
-            {
-                if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl))
-                {
-                    result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId;
-                }
-            }
+            var info = await GetPlaybackInfoInternal(itemId, userId, mediaSourceId, liveStreamId).ConfigureAwait(false);
 
-            if (result.MediaSource != null)
+            if (profile != null)
             {
-                NormalizeMediaSourceContainer(result.MediaSource, profile, DlnaProfileType.Video);
-            }
-
-            return result;
-        }
-
-        public void Post(CloseMediaSource request)
-        {
-            _mediaSourceManager.CloseLiveStream(request.LiveStreamId).GetAwaiter().GetResult();
-        }
-
-        public async Task<PlaybackInfoResponse> GetPlaybackInfo(GetPostedPlaybackInfo request)
-        {
-            var authInfo = _authContext.GetAuthorizationInfo(Request);
+                // set device specific data
+                var item = _libraryManager.GetItemById(itemId);
 
-            var profile = request.DeviceProfile;
-
-            Logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile);
-
-            if (profile == null)
-            {
-                var caps = _deviceManager.GetCapabilities(authInfo.DeviceId);
-                if (caps != null)
+                foreach (var mediaSource in info.MediaSources)
                 {
-                    profile = caps.DeviceProfile;
+                    SetDeviceSpecificData(
+                        item,
+                        mediaSource,
+                        profile,
+                        authInfo,
+                        maxStreamingBitrate ?? profile.MaxStreamingBitrate,
+                        startTimeTicks ?? 0,
+                        mediaSourceId ?? string.Empty,
+                        audioStreamIndex,
+                        subtitleStreamIndex,
+                        maxAudioChannels,
+                        info!.PlaySessionId!,
+                        userId ?? Guid.Empty,
+                        enableDirectPlay,
+                        enableDirectStream,
+                        enableTranscoding,
+                        allowVideoStreamCopy,
+                        allowAudioStreamCopy);
                 }
-            }
-
-            var info = await GetPlaybackInfo(request.Id, request.UserId, new[] { MediaType.Audio, MediaType.Video }, request.MediaSourceId, request.LiveStreamId).ConfigureAwait(false);
-
-            if (profile != null)
-            {
-                var mediaSourceId = request.MediaSourceId;
 
-                SetDeviceSpecificData(request.Id, info, profile, authInfo, request.MaxStreamingBitrate ?? profile.MaxStreamingBitrate, request.StartTimeTicks ?? 0, mediaSourceId, request.AudioStreamIndex, request.SubtitleStreamIndex, request.MaxAudioChannels, request.UserId, request.EnableDirectPlay, true, request.EnableDirectStream, request.EnableTranscoding, request.AllowVideoStreamCopy, request.AllowAudioStreamCopy);
+                SortMediaSources(info, maxStreamingBitrate);
             }
 
-            if (request.AutoOpenLiveStream)
+            if (autoOpenLiveStream)
             {
-                var mediaSource = string.IsNullOrWhiteSpace(request.MediaSourceId) ? info.MediaSources.FirstOrDefault() : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, request.MediaSourceId, StringComparison.Ordinal));
+                var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal));
 
                 if (mediaSource != null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId))
                 {
-                    var openStreamResult = await OpenMediaSource(new OpenMediaSource
+                    var openStreamResult = await OpenMediaSource(new LiveStreamRequest
                     {
-                        AudioStreamIndex = request.AudioStreamIndex,
-                        DeviceProfile = request.DeviceProfile,
-                        EnableDirectPlay = request.EnableDirectPlay,
-                        EnableDirectStream = request.EnableDirectStream,
-                        ItemId = request.Id,
-                        MaxAudioChannels = request.MaxAudioChannels,
-                        MaxStreamingBitrate = request.MaxStreamingBitrate,
+                        AudioStreamIndex = audioStreamIndex,
+                        DeviceProfile = deviceProfile?.DeviceProfile,
+                        EnableDirectPlay = enableDirectPlay,
+                        EnableDirectStream = enableDirectStream,
+                        ItemId = itemId,
+                        MaxAudioChannels = maxAudioChannels,
+                        MaxStreamingBitrate = maxStreamingBitrate,
                         PlaySessionId = info.PlaySessionId,
-                        StartTimeTicks = request.StartTimeTicks,
-                        SubtitleStreamIndex = request.SubtitleStreamIndex,
-                        UserId = request.UserId,
+                        StartTimeTicks = startTimeTicks,
+                        SubtitleStreamIndex = subtitleStreamIndex,
+                        UserId = userId ?? Guid.Empty,
                         OpenToken = mediaSource.OpenToken
                     }).ConfigureAwait(false);
 
@@ -243,36 +215,132 @@ namespace MediaBrowser.Api.Playback
             {
                 foreach (var mediaSource in info.MediaSources)
                 {
-                    NormalizeMediaSourceContainer(mediaSource, profile, DlnaProfileType.Video);
+                    NormalizeMediaSourceContainer(mediaSource, profile!, DlnaProfileType.Video);
                 }
             }
 
             return info;
         }
 
-        private void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type)
+        /// <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] long? maxStreamingBitrate,
+            [FromQuery] long? startTimeTicks,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] int? maxAudioChannels,
+            [FromQuery] Guid? itemId,
+            [FromBody] OpenLiveStreamDto openLiveStreamDto,
+            [FromQuery] bool enableDirectPlay = true,
+            [FromQuery] bool enableDirectStream = true)
         {
-            mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, mediaSource.Path, profile, type);
+            var request = new LiveStreamRequest
+            {
+                OpenToken = openToken,
+                UserId = userId ?? Guid.Empty,
+                PlaySessionId = playSessionId,
+                MaxStreamingBitrate = maxStreamingBitrate,
+                StartTimeTicks = startTimeTicks,
+                AudioStreamIndex = audioStreamIndex,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                MaxAudioChannels = maxAudioChannels,
+                ItemId = itemId ?? Guid.Empty,
+                DeviceProfile = openLiveStreamDto?.DeviceProfile,
+                EnableDirectPlay = enableDirectPlay,
+                EnableDirectStream = enableDirectStream,
+                DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http }
+            };
+            return await OpenMediaSource(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 ActionResult CloseLiveStream([FromQuery, Required] string? liveStreamId)
+        {
+            _mediaSourceManager.CloseLiveStream(liveStreamId).GetAwaiter().GetResult();
+            return NoContent();
         }
 
-        public async Task<object> Post(GetPostedPlaybackInfo request)
+        /// <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>
+        /// <response code="400">Size has to be a numer between 0 and 10,000,000.</response>
+        /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns>
+        [HttpGet("Playback/BitrateTest")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status400BadRequest)]
+        [Produces(MediaTypeNames.Application.Octet)]
+        public ActionResult GetBitrateTestBytes([FromQuery] int size = 102400)
         {
-            var result = await GetPlaybackInfo(request).ConfigureAwait(false);
+            const int MaxSize = 10_000_000;
+
+            if (size <= 0)
+            {
+                return BadRequest($"The requested size ({size}) is equal to or smaller than 0.");
+            }
 
-            return ToOptimizedResult(result);
+            if (size > MaxSize)
+            {
+                return BadRequest($"The requested size ({size}) is larger than the max allowed value ({MaxSize}).");
+            }
+
+            byte[] buffer = ArrayPool<byte>.Shared.Rent(size);
+            try
+            {
+                new Random().NextBytes(buffer);
+                return File(buffer, MediaTypeNames.Application.Octet);
+            }
+            finally
+            {
+                ArrayPool<byte>.Shared.Return(buffer);
+            }
         }
 
-        private async Task<PlaybackInfoResponse> GetPlaybackInfo(Guid id, Guid userId, string[] supportedLiveMediaTypes, string mediaSourceId = null, string liveStreamId = null)
+        private async Task<PlaybackInfoResponse> GetPlaybackInfoInternal(
+            Guid id,
+            Guid? userId,
+            string? mediaSourceId = null,
+            string? liveStreamId = null)
         {
-            var user = _userManager.GetUserById(userId);
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
             var item = _libraryManager.GetItemById(id);
             var result = new PlaybackInfoResponse();
 
             MediaSourceInfo[] mediaSources;
             if (string.IsNullOrWhiteSpace(liveStreamId))
             {
-
-                // TODO handle supportedLiveMediaTypes?
+                // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
                 var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
 
                 if (string.IsNullOrWhiteSpace(mediaSourceId))
@@ -297,10 +365,7 @@ namespace MediaBrowser.Api.Playback
             {
                 result.MediaSources = Array.Empty<MediaSourceInfo>();
 
-                if (!result.ErrorCode.HasValue)
-                {
-                    result.ErrorCode = PlaybackErrorCode.NoCompatibleStream;
-                }
+                result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream;
             }
             else
             {
@@ -314,33 +379,9 @@ namespace MediaBrowser.Api.Playback
             return result;
         }
 
-        private void SetDeviceSpecificData(
-            Guid itemId,
-            PlaybackInfoResponse result,
-            DeviceProfile profile,
-            AuthorizationInfo auth,
-            long? maxBitrate,
-            long startTimeTicks,
-            string mediaSourceId,
-            int? audioStreamIndex,
-            int? subtitleStreamIndex,
-            int? maxAudioChannels,
-            Guid userId,
-            bool enableDirectPlay,
-            bool forceDirectPlayRemoteMediaSource,
-            bool enableDirectStream,
-            bool enableTranscoding,
-            bool allowVideoStreamCopy,
-            bool allowAudioStreamCopy)
+        private void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type)
         {
-            var item = _libraryManager.GetItemById(itemId);
-
-            foreach (var mediaSource in result.MediaSources)
-            {
-                SetDeviceSpecificData(item, mediaSource, profile, auth, maxBitrate, startTimeTicks, mediaSourceId, audioStreamIndex, subtitleStreamIndex, maxAudioChannels, result.PlaySessionId, userId, enableDirectPlay, forceDirectPlayRemoteMediaSource, enableDirectStream, enableTranscoding, allowVideoStreamCopy, allowAudioStreamCopy);
-            }
-
-            SortMediaSources(result, maxBitrate);
+            mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, mediaSource.Path, profile, type);
         }
 
         private void SetDeviceSpecificData(
@@ -357,13 +398,12 @@ namespace MediaBrowser.Api.Playback
             string playSessionId,
             Guid userId,
             bool enableDirectPlay,
-            bool forceDirectPlayRemoteMediaSource,
             bool enableDirectStream,
             bool enableTranscoding,
             bool allowVideoStreamCopy,
             bool allowAudioStreamCopy)
         {
-            var streamBuilder = new StreamBuilder(_mediaEncoder, Logger);
+            var streamBuilder = new StreamBuilder(_mediaEncoder, _logger);
 
             var options = new VideoOptions
             {
@@ -401,14 +441,15 @@ namespace MediaBrowser.Api.Playback
 
             if (item is Audio)
             {
-                Logger.LogInformation(
+                _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}",
+                _logger.LogInformation(
+                    "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}",
                     user.Username,
                     user.HasPermission(PermissionKind.EnablePlaybackRemuxing),
                     user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding),
@@ -586,28 +627,57 @@ namespace MediaBrowser.Api.Playback
             }
         }
 
-        private long? GetMaxBitrate(long? clientMaxBitrate, Jellyfin.Data.Entities.User user)
+        private async Task<LiveStreamResponse> OpenMediaSource(LiveStreamRequest request)
         {
-            var maxBitrate = clientMaxBitrate;
-            var remoteClientMaxBitrate = user?.RemoteClientBitrateLimit ?? 0;
+            var authInfo = _authContext.GetAuthorizationInfo(Request);
 
-            if (remoteClientMaxBitrate <= 0)
+            var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
+
+            var profile = request.DeviceProfile;
+            if (profile == null)
             {
-                remoteClientMaxBitrate = ServerConfigurationManager.Configuration.RemoteClientBitrateLimit;
+                var caps = _deviceManager.GetCapabilities(authInfo.DeviceId);
+                if (caps != null)
+                {
+                    profile = caps.DeviceProfile;
+                }
             }
 
-            if (remoteClientMaxBitrate > 0)
+            if (profile != null)
             {
-                var isInLocalNetwork = _networkManager.IsInLocalNetwork(Request.RemoteIp);
+                var item = _libraryManager.GetItemById(request.ItemId);
 
-                Logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, Request.RemoteIp, isInLocalNetwork);
-                if (!isInLocalNetwork)
+                SetDeviceSpecificData(
+                    item,
+                    result.MediaSource,
+                    profile,
+                    authInfo,
+                    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);
+            }
+            else
+            {
+                if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl))
                 {
-                    maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate);
+                    result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId;
                 }
             }
 
-            return maxBitrate;
+            // here was a check if (result.MediaSource != null) but Rider said it will never be null
+            NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video);
+
+            return result;
         }
 
         private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken)
@@ -635,45 +705,73 @@ namespace MediaBrowser.Api.Playback
             }
         }
 
+        private long? GetMaxBitrate(long? clientMaxBitrate, User user)
+        {
+            var maxBitrate = clientMaxBitrate;
+            var remoteClientMaxBitrate = user?.RemoteClientBitrateLimit ?? 0;
+
+            if (remoteClientMaxBitrate <= 0)
+            {
+                remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit;
+            }
+
+            if (remoteClientMaxBitrate > 0)
+            {
+                var isInLocalNetwork = _networkManager.IsInLocalNetwork(Request.HttpContext.Connection.RemoteIpAddress.ToString());
+
+                _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, Request.HttpContext.Connection.RemoteIpAddress.ToString(), isInLocalNetwork);
+                if (!isInLocalNetwork)
+                {
+                    maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate);
+                }
+            }
+
+            return maxBitrate;
+        }
+
         private void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
         {
             var originalList = result.MediaSources.ToList();
 
             result.MediaSources = result.MediaSources.OrderBy(i =>
-            {
-                // Nothing beats direct playing a file
-                if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
                 {
-                    return 0;
-                }
+                    // Nothing beats direct playing a file
+                    if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
+                    {
+                        return 0;
+                    }
 
-                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)
+                    return 1;
+                })
+                .ThenBy(i =>
                 {
-                    return 0;
-                }
+                    // 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
+                    return 1;
+                })
+                .ThenBy(i =>
                 {
-                    MediaProtocol.File => 0,
-                    _ => 1,
-                };
-            }).ThenBy(i =>
-            {
-                if (maxBitrate.HasValue && i.Bitrate.HasValue)
+                    return i.Protocol switch
+                    {
+                        MediaProtocol.File => 0,
+                        _ => 1,
+                    };
+                })
+                .ThenBy(i =>
                 {
-                    return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2;
-                }
+                    if (maxBitrate.HasValue && i.Bitrate.HasValue)
+                    {
+                        return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2;
+                    }
 
-                return 1;
-            }).ThenBy(originalList.IndexOf)
-            .ToArray();
+                    return 1;
+                })
+                .ThenBy(originalList.IndexOf)
+                .ToArray();
         }
     }
 }

+ 330 - 0
Jellyfin.Api/Controllers/MoviesController.cs

@@ -0,0 +1,330 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] int categoryLimit = 5,
+            [FromQuery] int itemLimit = 8)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request);
+
+            var categories = new List<RecommendationDto>();
+
+            var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+
+            var query = new InternalItemsQuery(user)
+            {
+                IncludeItemTypes = new[]
+                {
+                    nameof(Movie),
+                    // typeof(Trailer).Name,
+                    // typeof(LiveTvProgram).Name
+                },
+                // IsMovie = true
+                OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
+                Limit = 7,
+                ParentId = parentIdGuid,
+                Recursive = true,
+                IsPlayed = true,
+                DtoOptions = dtoOptions
+            };
+
+            var recentlyPlayedMovies = _libraryManager.GetItemList(query);
+
+            var itemTypes = new List<string> { nameof(Movie) };
+            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+            {
+                itemTypes.Add(nameof(Trailer));
+                itemTypes.Add(nameof(LiveTvProgram));
+            }
+
+            var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
+            {
+                IncludeItemTypes = itemTypes.ToArray(),
+                IsMovie = true,
+                OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
+                Limit = 10,
+                IsFavoriteOrLiked = true,
+                ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
+                EnableGroupByMetadataKey = true,
+                ParentId = parentIdGuid,
+                Recursive = true,
+                DtoOptions = dtoOptions
+            });
+
+            var mostRecentMovies = recentlyPlayedMovies.Take(6).ToList();
+            // 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,
+                similarToRecentlyPlayed,
+
+                // Give this extra weight
+                similarToLiked,
+                similarToLiked,
+                hasDirectorFromRecentlyPlayed,
+                hasActorFromRecentlyPlayed
+            };
+
+            while (categories.Count < categoryLimit)
+            {
+                var allEmpty = true;
+
+                foreach (var category in categoryTypes)
+                {
+                    if (category.MoveNext())
+                    {
+                        categories.Add(category.Current);
+                        allEmpty = false;
+
+                        if (categories.Count >= categoryLimit)
+                        {
+                            break;
+                        }
+                    }
+                }
+
+                if (allEmpty)
+                {
+                    break;
+                }
+            }
+
+            return Ok(categories.OrderBy(i => i.RecommendationType));
+        }
+
+        private IEnumerable<RecommendationDto> GetWithDirector(
+            User? user,
+            IEnumerable<string> names,
+            int itemLimit,
+            DtoOptions dtoOptions,
+            RecommendationType type)
+        {
+            var itemTypes = new List<string> { nameof(MediaBrowser.Controller.Entities.Movies.Movie) };
+            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+            {
+                itemTypes.Add(nameof(Trailer));
+                itemTypes.Add(nameof(LiveTvProgram));
+            }
+
+            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
+                    }).GroupBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
+                    .Select(x => x.First())
+                    .Take(itemLimit)
+                    .ToList();
+
+                if (items.Count > 0)
+                {
+                    var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
+
+                    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<string> { nameof(Movie) };
+            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+            {
+                itemTypes.Add(nameof(Trailer));
+                itemTypes.Add(nameof(LiveTvProgram));
+            }
+
+            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,
+                        IncludeItemTypes = itemTypes.ToArray(),
+                        IsMovie = true,
+                        EnableGroupByMetadataKey = true,
+                        DtoOptions = dtoOptions
+                    }).GroupBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
+                    .Select(x => x.First())
+                    .Take(itemLimit)
+                    .ToList();
+
+                if (items.Count > 0)
+                {
+                    var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
+
+                    yield return new RecommendationDto
+                    {
+                        BaselineItemName = name,
+                        CategoryId = name.GetMD5(),
+                        RecommendationType = type,
+                        Items = returnItems
+                    };
+                }
+            }
+        }
+
+        private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
+        {
+            var itemTypes = new List<string> { nameof(Movie) };
+            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+            {
+                itemTypes.Add(nameof(Trailer));
+                itemTypes.Add(nameof(LiveTvProgram));
+            }
+
+            foreach (var item in baselineItems)
+            {
+                var similar = _libraryManager.GetItemList(new InternalItemsQuery(user)
+                {
+                    Limit = itemLimit,
+                    IncludeItemTypes = itemTypes.ToArray(),
+                    IsMovie = true,
+                    SimilarTo = item,
+                    EnableGroupByMetadataKey = true,
+                    DtoOptions = dtoOptions
+                });
+
+                if (similar.Count > 0)
+                {
+                    var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user);
+
+                    yield return new RecommendationDto
+                    {
+                        BaselineItemName = item.Name,
+                        CategoryId = item.Id,
+                        RecommendationType = type,
+                        Items = returnItems
+                    };
+                }
+            }
+        }
+
+        private IEnumerable<string> GetActors(IEnumerable<BaseItem> items)
+        {
+            var people = _libraryManager.GetPeople(new InternalPeopleQuery
+            {
+                ExcludePersonTypes = new[] { PersonType.Director },
+                MaxListOrder = 3
+            });
+
+            var itemIds = items.Select(i => i.Id).ToList();
+
+            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
+            {
+                PersonTypes = new[] { PersonType.Director }
+            });
+
+            var itemIds = items.Select(i => i.Id).ToList();
+
+            return people
+                .Where(i => itemIds.Contains(i.ItemId))
+                .Select(i => i.Name)
+                .DistinctNames();
+        }
+    }
+}

+ 313 - 0
Jellyfin.Api/Controllers/MusicGenresController.cs

@@ -0,0 +1,313 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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;
+        }
+
+        /// <summary>
+        /// Gets all music genres 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">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="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="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="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 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="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="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]
+        public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres(
+            [FromQuery] double? minCommunityRating,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? searchTerm,
+            [FromQuery] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? genres,
+            [FromQuery] string? genreIds,
+            [FromQuery] string? officialRatings,
+            [FromQuery] string? tags,
+            [FromQuery] string? years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] string? person,
+            [FromQuery] string? personIds,
+            [FromQuery] string? personTypes,
+            [FromQuery] string? studios,
+            [FromQuery] string? studioIds,
+            [FromQuery] Guid? userId,
+            [FromQuery] string? nameStartsWithOrGreater,
+            [FromQuery] string? nameStartsWith,
+            [FromQuery] string? nameLessThan,
+            [FromQuery] bool? enableImages = true,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            User? user = null;
+            BaseItem parentItem;
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                user = _userManager.GetUserById(userId.Value);
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
+            }
+            else
+            {
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
+            }
+
+            var query = new InternalItemsQuery(user)
+            {
+                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+                StartIndex = startIndex,
+                Limit = limit,
+                IsFavorite = isFavorite,
+                NameLessThan = nameLessThan,
+                NameStartsWith = nameStartsWith,
+                NameStartsWithOrGreater = nameStartsWithOrGreater,
+                Tags = RequestHelpers.Split(tags, '|', true),
+                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
+                Genres = RequestHelpers.Split(genres, '|', true),
+                GenreIds = RequestHelpers.GetGuids(genreIds),
+                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Person = person,
+                PersonIds = RequestHelpers.GetGuids(personIds),
+                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
+                Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(),
+                MinCommunityRating = minCommunityRating,
+                DtoOptions = dtoOptions,
+                SearchTerm = searchTerm,
+                EnableTotalRecordCount = enableTotalRecordCount
+            };
+
+            if (!string.IsNullOrWhiteSpace(parentId))
+            {
+                if (parentItem is Folder)
+                {
+                    query.AncestorIds = new[] { new Guid(parentId) };
+                }
+                else
+                {
+                    query.ItemIds = new[] { new Guid(parentId) };
+                }
+            }
+
+            // Studios
+            if (!string.IsNullOrEmpty(studios))
+            {
+                query.StudioIds = studios.Split('|')
+                    .Select(i =>
+                    {
+                        try
+                        {
+                            return _libraryManager.GetStudio(i);
+                        }
+                        catch
+                        {
+                            return null;
+                        }
+                    }).Where(i => i != null)
+                    .Select(i => i!.Id)
+                    .ToArray();
+            }
+
+            foreach (var filter in RequestHelpers.GetFilters(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.GetMusicGenres(query);
+
+            var dtos = result.Items.Select(i =>
+            {
+                var (baseItem, counts) = i;
+                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+                if (!string.IsNullOrWhiteSpace(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>
+            {
+                Items = dtos.ToArray(),
+                TotalRecordCount = result.TotalRecordCount
+            };
+        }
+
+        /// <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] string genreName, [FromQuery] Guid? userId)
+        {
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            MusicGenre item;
+
+            if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1)
+            {
+                item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions);
+            }
+            else
+            {
+                item = _libraryManager.GetMusicGenre(genreName);
+            }
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                var user = _userManager.GetUserById(userId.Value);
+
+                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+            }
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions);
+        }
+
+        private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions)
+            where T : BaseItem, new()
+        {
+            var result = libraryManager.GetItemList(new InternalItemsQuery
+            {
+                Name = name.Replace(BaseItem.SlugChar, '&'),
+                IncludeItemTypes = new[] { typeof(T).Name },
+                DtoOptions = dtoOptions
+            }).OfType<T>().FirstOrDefault();
+
+            result ??= libraryManager.GetItemList(new InternalItemsQuery
+            {
+                Name = name.Replace(BaseItem.SlugChar, '/'),
+                IncludeItemTypes = new[] { typeof(T).Name },
+                DtoOptions = dtoOptions
+            }).OfType<T>().FirstOrDefault();
+
+            result ??= libraryManager.GetItemList(new InternalItemsQuery
+            {
+                Name = name.Replace(BaseItem.SlugChar, '?'),
+                IncludeItemTypes = new[] { typeof(T).Name },
+                DtoOptions = dtoOptions
+            }).OfType<T>().FirstOrDefault();
+
+            return result;
+        }
+    }
+}

+ 146 - 0
Jellyfin.Api/Controllers/NotificationsController.cs

@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.NotificationDtos;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Notifications;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Notifications;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The notification controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class NotificationsController : BaseJellyfinApiController
+    {
+        private readonly INotificationManager _notificationManager;
+        private readonly IUserManager _userManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="NotificationsController" /> class.
+        /// </summary>
+        /// <param name="notificationManager">The notification manager.</param>
+        /// <param name="userManager">The user manager.</param>
+        public NotificationsController(INotificationManager notificationManager, IUserManager userManager)
+        {
+            _notificationManager = notificationManager;
+            _userManager = userManager;
+        }
+
+        /// <summary>
+        /// Gets a user's notifications.
+        /// </summary>
+        /// <response code="200">Notifications returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing a list of notifications.</returns>
+        [HttpGet("{userId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<NotificationResultDto> GetNotifications()
+        {
+            return new NotificationResultDto();
+        }
+
+        /// <summary>
+        /// Gets a user's notification summary.
+        /// </summary>
+        /// <response code="200">Summary of user's notifications returned.</response>
+        /// <returns>An <cref see="OkResult"/> containing a summary of the users notifications.</returns>
+        [HttpGet("{userId}/Summary")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<NotificationsSummaryDto> GetNotificationsSummary()
+        {
+            return new NotificationsSummaryDto();
+        }
+
+        /// <summary>
+        /// Gets notification types.
+        /// </summary>
+        /// <response code="200">All notification types returned.</response>
+        /// <returns>An <cref see="OkResult"/> containing a list of all notification types.</returns>
+        [HttpGet("Types")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public IEnumerable<NotificationTypeInfo> GetNotificationTypes()
+        {
+            return _notificationManager.GetNotificationTypes();
+        }
+
+        /// <summary>
+        /// Gets notification services.
+        /// </summary>
+        /// <response code="200">All notification services returned.</response>
+        /// <returns>An <cref see="OkResult"/> containing a list of all notification services.</returns>
+        [HttpGet("Services")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public IEnumerable<NameIdPair> GetNotificationServices()
+        {
+            return _notificationManager.GetNotificationServices();
+        }
+
+        /// <summary>
+        /// Sends a notification to all admins.
+        /// </summary>
+        /// <param name="url">The URL of the notification.</param>
+        /// <param name="level">The level of the notification.</param>
+        /// <param name="name">The name of the notification.</param>
+        /// <param name="description">The description of the notification.</param>
+        /// <response code="204">Notification sent.</response>
+        /// <returns>A <cref see="NoContentResult"/>.</returns>
+        [HttpPost("Admin")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult CreateAdminNotification(
+            [FromQuery] string? url,
+            [FromQuery] NotificationLevel? level,
+            [FromQuery] string name = "",
+            [FromQuery] string description = "")
+        {
+            var notification = new NotificationRequest
+            {
+                Name = name,
+                Description = description,
+                Url = url,
+                Level = level ?? NotificationLevel.Normal,
+                UserIds = _userManager.Users
+                    .Where(user => user.HasPermission(PermissionKind.IsAdministrator))
+                    .Select(user => user.Id)
+                    .ToArray(),
+                Date = DateTime.UtcNow,
+            };
+
+            _notificationManager.SendNotification(notification, CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Sets notifications as read.
+        /// </summary>
+        /// <response code="204">Notifications set as read.</response>
+        /// <returns>A <cref see="NoContentResult"/>.</returns>
+        [HttpPost("{userId}/Read")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SetRead()
+        {
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Sets notifications as unread.
+        /// </summary>
+        /// <response code="204">Notifications set as unread.</response>
+        /// <returns>A <cref see="NoContentResult"/>.</returns>
+        [HttpPost("{userId}/Unread")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SetUnread()
+        {
+            return NoContent();
+        }
+    }
+}

+ 151 - 0
Jellyfin.Api/Controllers/PackageController.cs

@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Updates;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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;
+        }
+
+        /// <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] string? assemblyGuid)
+        {
+            var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
+            var result = _installationManager.FilterPackages(
+                packages,
+                name,
+                string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid)).FirstOrDefault();
+
+            return result;
+        }
+
+        /// <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 packages;
+        }
+
+        /// <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>
+        /// <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] string? assemblyGuid,
+            [FromQuery] string? version)
+        {
+            var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
+            var package = _installationManager.GetCompatibleVersions(
+                    packages,
+                    name,
+                    string.IsNullOrEmpty(assemblyGuid) ? Guid.Empty : Guid.Parse(assemblyGuid),
+                    string.IsNullOrEmpty(version) ? null : Version.Parse(version)).FirstOrDefault();
+
+            if (package == null)
+            {
+                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 _serverConfigurationManager.Configuration.PluginRepositories;
+        }
+
+        /// <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>
+        [HttpOptions("Repositories")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SetRepositories([FromBody] List<RepositoryInfo> repositoryInfos)
+        {
+            _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos;
+            return NoContent();
+        }
+    }
+}

+ 285 - 0
Jellyfin.Api/Controllers/PersonsController.cs

@@ -0,0 +1,285 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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 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">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="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="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="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 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="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="enableImages">Optional, include image information in output.</param>
+        /// <param name="enableTotalRecordCount">Optional. Include total record count.</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] double? minCommunityRating,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? searchTerm,
+            [FromQuery] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? genres,
+            [FromQuery] string? genreIds,
+            [FromQuery] string? officialRatings,
+            [FromQuery] string? tags,
+            [FromQuery] string? years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] string? person,
+            [FromQuery] string? personIds,
+            [FromQuery] string? personTypes,
+            [FromQuery] string? studios,
+            [FromQuery] string? studioIds,
+            [FromQuery] Guid? userId,
+            [FromQuery] string? nameStartsWithOrGreater,
+            [FromQuery] string? nameStartsWith,
+            [FromQuery] string? nameLessThan,
+            [FromQuery] bool? enableImages = true,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            User? user = null;
+            BaseItem parentItem;
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                user = _userManager.GetUserById(userId.Value);
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
+            }
+            else
+            {
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
+            }
+
+            var query = new InternalItemsQuery(user)
+            {
+                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+                StartIndex = startIndex,
+                Limit = limit,
+                IsFavorite = isFavorite,
+                NameLessThan = nameLessThan,
+                NameStartsWith = nameStartsWith,
+                NameStartsWithOrGreater = nameStartsWithOrGreater,
+                Tags = RequestHelpers.Split(tags, '|', true),
+                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
+                Genres = RequestHelpers.Split(genres, '|', true),
+                GenreIds = RequestHelpers.GetGuids(genreIds),
+                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Person = person,
+                PersonIds = RequestHelpers.GetGuids(personIds),
+                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
+                Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(),
+                MinCommunityRating = minCommunityRating,
+                DtoOptions = dtoOptions,
+                SearchTerm = searchTerm,
+                EnableTotalRecordCount = enableTotalRecordCount
+            };
+
+            if (!string.IsNullOrWhiteSpace(parentId))
+            {
+                if (parentItem is Folder)
+                {
+                    query.AncestorIds = new[] { new Guid(parentId) };
+                }
+                else
+                {
+                    query.ItemIds = new[] { new Guid(parentId) };
+                }
+            }
+
+            // Studios
+            if (!string.IsNullOrEmpty(studios))
+            {
+                query.StudioIds = studios.Split('|')
+                    .Select(i =>
+                    {
+                        try
+                        {
+                            return _libraryManager.GetStudio(i);
+                        }
+                        catch
+                        {
+                            return null;
+                        }
+                    }).Where(i => i != null)
+                    .Select(i => i!.Id)
+                    .ToArray();
+            }
+
+            foreach (var filter in RequestHelpers.GetFilters(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 = new QueryResult<(BaseItem, ItemCounts)>();
+
+            var dtos = result.Items.Select(i =>
+            {
+                var (baseItem, counts) = i;
+                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+                if (!string.IsNullOrWhiteSpace(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>
+            {
+                Items = dtos.ToArray(),
+                TotalRecordCount = result.TotalRecordCount
+            };
+        }
+
+        /// <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] string name, [FromQuery] Guid? userId)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddClientFields(Request);
+
+            var item = _libraryManager.GetPerson(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                var user = _userManager.GetUserById(userId.Value);
+                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+            }
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions);
+        }
+    }
+}

+ 199 - 0
Jellyfin.Api/Controllers/PlaylistsController.cs

@@ -0,0 +1,199 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.PlaylistDtos;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Playlists;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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;
+        }
+
+        /// <summary>
+        /// Creates a new playlist.
+        /// </summary>
+        /// <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(
+            [FromBody, Required] CreatePlaylistDto createPlaylistRequest)
+        {
+            Guid[] idGuidArray = RequestHelpers.GetGuids(createPlaylistRequest.Ids);
+            var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
+            {
+                Name = createPlaylistRequest.Name,
+                ItemIdList = idGuidArray,
+                UserId = createPlaylistRequest.UserId,
+                MediaType = createPlaylistRequest.MediaType
+            }).ConfigureAwait(false);
+
+            return result;
+        }
+
+        /// <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 ActionResult AddToPlaylist(
+            [FromRoute] string? playlistId,
+            [FromQuery] string? ids,
+            [FromQuery] Guid? userId)
+        {
+            _playlistManager.AddToPlaylist(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty);
+            return NoContent();
+        }
+
+        /// <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 ActionResult MoveItem(
+            [FromRoute] string? playlistId,
+            [FromRoute] string? itemId,
+            [FromRoute] int newIndex)
+        {
+            _playlistManager.MoveItem(playlistId, itemId, newIndex);
+            return NoContent();
+        }
+
+        /// <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 ActionResult RemoveFromPlaylist([FromRoute] string? playlistId, [FromQuery] string? entryIds)
+        {
+            _playlistManager.RemoveFromPlaylist(playlistId, RequestHelpers.Split(entryIds, ',', true));
+            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. 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="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] Guid playlistId,
+            [FromRoute] Guid userId,
+            [FromRoute] int? startIndex,
+            [FromRoute] int? limit,
+            [FromRoute] string? fields,
+            [FromRoute] bool? enableImages,
+            [FromRoute] bool? enableUserData,
+            [FromRoute] int? imageTypeLimit,
+            [FromRoute] string? enableImageTypes)
+        {
+            var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
+            if (playlist == null)
+            {
+                return NotFound();
+            }
+
+            var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null;
+
+            var items = playlist.GetManageableItems().ToArray();
+
+            var count = items.Length;
+
+            if (startIndex.HasValue)
+            {
+                items = items.Skip(startIndex.Value).ToArray();
+            }
+
+            if (limit.HasValue)
+            {
+                items = items.Take(limit.Value).ToArray();
+            }
+
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user);
+
+            for (int index = 0; index < dtos.Count; index++)
+            {
+                dtos[index].PlaylistItemId = items[index].Item1.Id;
+            }
+
+            var result = new QueryResult<BaseItemDto>
+            {
+                Items = dtos,
+                TotalRecordCount = count
+            };
+
+            return result;
+        }
+    }
+}

+ 367 - 0
Jellyfin.Api/Controllers/PlaystateController.cs

@@ -0,0 +1,367 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Session;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+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 IAuthorizationContext _authContext;
+        private readonly ILogger<PlaystateController> _logger;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+
+        /// <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="authContext">Instance of the <see cref="IAuthorizationContext"/> 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,
+            IAuthorizationContext authContext,
+            ILoggerFactory loggerFactory,
+            TranscodingJobHelper transcodingJobHelper)
+        {
+            _userManager = userManager;
+            _userDataRepository = userDataRepository;
+            _libraryManager = libraryManager;
+            _sessionManager = sessionManager;
+            _authContext = authContext;
+            _logger = loggerFactory.CreateLogger<PlaystateController>();
+
+            _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>
+        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+        [HttpPost("Users/{userId}/PlayedItems/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<UserItemDataDto> MarkPlayedItem(
+            [FromRoute] Guid userId,
+            [FromRoute] Guid itemId,
+            [FromQuery] DateTime? datePlayed)
+        {
+            var user = _userManager.GetUserById(userId);
+            var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+            var dto = UpdatePlayedStatus(user, itemId, true, datePlayed);
+            foreach (var additionalUserInfo in session.AdditionalUsers)
+            {
+                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
+                UpdatePlayedStatus(additionalUser, itemId, true, datePlayed);
+            }
+
+            return dto;
+        }
+
+        /// <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>
+        /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+        [HttpDelete("Users/{userId}/PlayedItem/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<UserItemDataDto> MarkUnplayedItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
+        {
+            var user = _userManager.GetUserById(userId);
+            var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+            var dto = UpdatePlayedStatus(user, itemId, false, null);
+            foreach (var additionalUserInfo in session.AdditionalUsers)
+            {
+                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
+                UpdatePlayedStatus(additionalUser, itemId, false, null);
+            }
+
+            return dto;
+        }
+
+        /// <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 = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
+            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)
+        {
+            playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
+            playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackProgress(playbackProgressInfo).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] string playSessionId)
+        {
+            _transcodingJobHelper.PingTranscodingJob(playSessionId, null);
+            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)
+        {
+            _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
+            if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
+            {
+                await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
+            }
+
+            playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStopped(playbackStopInfo).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] Guid userId,
+            [FromRoute] 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
+            {
+                CanSeek = canSeek,
+                ItemId = itemId,
+                MediaSourceId = mediaSourceId,
+                AudioStreamIndex = audioStreamIndex,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                PlayMethod = playMethod,
+                PlaySessionId = playSessionId,
+                LiveStreamId = liveStreamId
+            };
+
+            playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
+            playbackStartInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStart(playbackStartInfo).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] Guid userId,
+            [FromRoute] 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
+            {
+                ItemId = itemId,
+                PositionTicks = positionTicks,
+                IsMuted = isMuted,
+                IsPaused = isPaused,
+                MediaSourceId = mediaSourceId,
+                AudioStreamIndex = audioStreamIndex,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                VolumeLevel = volumeLevel,
+                PlayMethod = playMethod,
+                PlaySessionId = playSessionId,
+                LiveStreamId = liveStreamId,
+                RepeatMode = repeatMode
+            };
+
+            playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
+            playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <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] Guid userId,
+            [FromRoute] Guid itemId,
+            [FromQuery] string? mediaSourceId,
+            [FromQuery] string? nextMediaType,
+            [FromQuery] long? positionTicks,
+            [FromQuery] string? liveStreamId,
+            [FromQuery] string? playSessionId)
+        {
+            var playbackStopInfo = new PlaybackStopInfo
+            {
+                ItemId = itemId,
+                PositionTicks = positionTicks,
+                MediaSourceId = mediaSourceId,
+                PlaySessionId = playSessionId,
+                LiveStreamId = liveStreamId,
+                NextMediaType = nextMediaType
+            };
+
+            _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
+            if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
+            {
+                await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
+            }
+
+            playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Updates the played status.
+        /// </summary>
+        /// <param name="user">The user.</param>
+        /// <param name="itemId">The item id.</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, Guid itemId, bool wasPlayed, DateTime? datePlayed)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+
+            if (wasPlayed)
+            {
+                item.MarkPlayed(user, datePlayed, true);
+            }
+            else
+            {
+                item.MarkUnplayed(user);
+            }
+
+            return _userDataRepository.GetUserDataDto(item, user);
+        }
+
+        private PlayMethod ValidatePlayMethod(PlayMethod method, string playSessionId)
+        {
+            if (method == PlayMethod.Transcode)
+            {
+                var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId);
+                if (job == null)
+                {
+                    return PlayMethod.DirectPlay;
+                }
+            }
+
+            return method;
+        }
+    }
+}

+ 200 - 0
Jellyfin.Api/Controllers/PluginsController.cs

@@ -0,0 +1,200 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.PluginDtos;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Json;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Model.Plugins;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Plugins controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class PluginsController : BaseJellyfinApiController
+    {
+        private readonly IApplicationHost _appHost;
+        private readonly IInstallationManager _installationManager;
+
+        private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.GetOptions();
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PluginsController"/> class.
+        /// </summary>
+        /// <param name="appHost">Instance of the <see cref="IApplicationHost"/> interface.</param>
+        /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
+        public PluginsController(
+            IApplicationHost appHost,
+            IInstallationManager installationManager)
+        {
+            _appHost = appHost;
+            _installationManager = installationManager;
+        }
+
+        /// <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(_appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo()));
+        }
+
+        /// <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 file could not be found.</returns>
+        [HttpDelete("{pluginId}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult UninstallPlugin([FromRoute] Guid pluginId)
+        {
+            var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId);
+            if (plugin == null)
+            {
+                return NotFound();
+            }
+
+            _installationManager.UninstallPlugin(plugin);
+            return NoContent();
+        }
+
+        /// <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] Guid pluginId)
+        {
+            if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
+            {
+                return NotFound();
+            }
+
+            return plugin.Configuration;
+        }
+
+        /// <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>
+        /// A <see cref="Task" /> that represents the asynchronous operation to update plugin configuration.
+        ///    The task result contains an <see cref="NoContentResult"/> indicating success, or <see cref="NotFoundResult"/>
+        ///    when plugin not found or plugin doesn't have configuration.
+        /// </returns>
+        [HttpPost("{pluginId}/Configuration")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> UpdatePluginConfiguration([FromRoute] Guid pluginId)
+        {
+            if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
+            {
+                return NotFound();
+            }
+
+            var configuration = (BasePluginConfiguration)await JsonSerializer.DeserializeAsync(Request.Body, plugin.ConfigurationType, _serializerOptions)
+                .ConfigureAwait(false);
+
+            plugin.UpdateConfiguration(configuration);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Get plugin security info.
+        /// </summary>
+        /// <response code="200">Plugin security info returned.</response>
+        /// <returns>Plugin security info.</returns>
+        [Obsolete("This endpoint should not be used.")]
+        [HttpGet("SecurityInfo")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<PluginSecurityInfo> GetPluginSecurityInfo()
+        {
+            return new PluginSecurityInfo
+            {
+                IsMbSupporter = true,
+                SupporterKey = "IAmTotallyLegit"
+            };
+        }
+
+        /// <summary>
+        /// Updates plugin security info.
+        /// </summary>
+        /// <param name="pluginSecurityInfo">Plugin security info.</param>
+        /// <response code="204">Plugin security info updated.</response>
+        /// <returns>An <see cref="NoContentResult"/>.</returns>
+        [Obsolete("This endpoint should not be used.")]
+        [HttpPost("SecurityInfo")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult UpdatePluginSecurityInfo([FromBody, Required] PluginSecurityInfo pluginSecurityInfo)
+        {
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Gets registration status for a feature.
+        /// </summary>
+        /// <param name="name">Feature name.</param>
+        /// <response code="200">Registration status returned.</response>
+        /// <returns>Mb registration record.</returns>
+        [Obsolete("This endpoint should not be used.")]
+        [HttpPost("RegistrationRecords/{name}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string? name)
+        {
+            return new MBRegistrationRecord
+            {
+                IsRegistered = true,
+                RegChecked = true,
+                TrialVersion = false,
+                IsValid = true,
+                RegError = false
+            };
+        }
+
+        /// <summary>
+        /// Gets registration status for a feature.
+        /// </summary>
+        /// <param name="name">Feature name.</param>
+        /// <response code="501">Not implemented.</response>
+        /// <returns>Not Implemented.</returns>
+        /// <exception cref="NotImplementedException">This endpoint is not implemented.</exception>
+        [Obsolete("Paid plugins are not supported")]
+        [HttpGet("Registrations/{name}")]
+        [ProducesResponseType(StatusCodes.Status501NotImplemented)]
+        public ActionResult GetRegistration([FromRoute] string? name)
+        {
+            // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins,
+            // delete all these registration endpoints. They are only kept for compatibility.
+            throw new NotImplementedException();
+        }
+    }
+}

+ 268 - 0
Jellyfin.Api/Controllers/RemoteImageController.cs

@@ -0,0 +1,268 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Providers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Remote Images Controller.
+    /// </summary>
+    [Route("")]
+    public class RemoteImageController : BaseJellyfinApiController
+    {
+        private readonly IProviderManager _providerManager;
+        private readonly IServerApplicationPaths _applicationPaths;
+        private readonly IHttpClient _httpClient;
+        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="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        public RemoteImageController(
+            IProviderManager providerManager,
+            IServerApplicationPaths applicationPaths,
+            IHttpClient httpClient,
+            ILibraryManager libraryManager)
+        {
+            _providerManager = providerManager;
+            _applicationPaths = applicationPaths;
+            _httpClient = httpClient;
+            _libraryManager = libraryManager;
+        }
+
+        /// <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] Guid itemId,
+            [FromQuery] ImageType? type,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? providerName,
+            [FromQuery] bool includeAllLanguages = false)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == 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;
+        }
+
+        /// <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] Guid itemId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return Ok(_providerManager.GetRemoteImageProviderInfo(item));
+        }
+
+        /// <summary>
+        /// Gets a remote image.
+        /// </summary>
+        /// <param name="imageUrl">The image url.</param>
+        /// <response code="200">Remote image returned.</response>
+        /// <response code="404">Remote image not found.</response>
+        /// <returns>Image Stream.</returns>
+        [HttpGet("Images/Remote")]
+        [Produces(MediaTypeNames.Application.Octet)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> GetRemoteImage([FromQuery, Required] string imageUrl)
+        {
+            var urlHash = imageUrl.GetMD5();
+            var pointerCachePath = GetFullCachePath(urlHash.ToString());
+
+            string? contentPath = null;
+            var hasFile = false;
+
+            try
+            {
+                contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false);
+                if (System.IO.File.Exists(contentPath))
+                {
+                    hasFile = true;
+                }
+            }
+            catch (FileNotFoundException)
+            {
+                // The file isn't cached yet
+            }
+            catch (IOException)
+            {
+                // The file isn't cached yet
+            }
+
+            if (!hasFile)
+            {
+                await DownloadImage(imageUrl, urlHash, pointerCachePath).ConfigureAwait(false);
+                contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false);
+            }
+
+            if (string.IsNullOrEmpty(contentPath))
+            {
+                return NotFound();
+            }
+
+            var contentType = MimeTypes.GetMimeType(contentPath);
+            return File(System.IO.File.OpenRead(contentPath), contentType);
+        }
+
+        /// <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] Guid itemId,
+            [FromQuery, Required] ImageType type,
+            [FromQuery] string? imageUrl)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None)
+                .ConfigureAwait(false);
+
+            item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None);
+            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);
+        }
+
+        /// <summary>
+        /// Downloads the image.
+        /// </summary>
+        /// <param name="url">The URL.</param>
+        /// <param name="urlHash">The URL hash.</param>
+        /// <param name="pointerCachePath">The pointer cache path.</param>
+        /// <returns>Task.</returns>
+        private async Task DownloadImage(string url, Guid urlHash, string pointerCachePath)
+        {
+            using var result = await _httpClient.GetResponse(new HttpRequestOptions
+            {
+                Url = url,
+                BufferContent = false
+            }).ConfigureAwait(false);
+            var ext = result.ContentType.Split('/').Last();
+
+            var fullCachePath = GetFullCachePath(urlHash + "." + ext);
+
+            Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath));
+            await using (var stream = result.Content)
+            {
+                await using var fileStream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
+                await stream.CopyToAsync(fileStream).ConfigureAwait(false);
+            }
+
+            Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath));
+            await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath, CancellationToken.None)
+                .ConfigureAwait(false);
+        }
+    }
+}

+ 161 - 0
Jellyfin.Api/Controllers/ScheduledTasksController.cs

@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Model.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Scheduled Tasks Controller.
+    /// </summary>
+    [Authorize(Policy = Policies.RequiresElevation)]
+    public class ScheduledTasksController : BaseJellyfinApiController
+    {
+        private readonly ITaskManager _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<IScheduledTaskWorker> GetTasks(
+            [FromQuery] bool? isHidden,
+            [FromQuery] bool? isEnabled)
+        {
+            IEnumerable<IScheduledTaskWorker> tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name);
+
+            foreach (var task in tasks)
+            {
+                if (task.ScheduledTask is IConfigurableScheduledTask scheduledTask)
+                {
+                    if (isHidden.HasValue && isHidden.Value != scheduledTask.IsHidden)
+                    {
+                        continue;
+                    }
+
+                    if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled)
+                    {
+                        continue;
+                    }
+                }
+
+                yield return task;
+            }
+        }
+
+        /// <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));
+
+            if (task == null)
+            {
+                return NotFound();
+            }
+
+            return ScheduledTaskHelpers.GetTaskInfo(task);
+        }
+
+        /// <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] string? taskId)
+        {
+            var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+                o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+
+            if (task == null)
+            {
+                return NotFound();
+            }
+
+            _taskManager.Execute(task, new TaskOptions());
+            return NoContent();
+        }
+
+        /// <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));
+
+            if (task == null)
+            {
+                return NotFound();
+            }
+
+            _taskManager.Cancel(task);
+            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 == null)
+            {
+                return NotFound();
+            }
+
+            task.Triggers = triggerInfos;
+            return NoContent();
+        }
+    }
+}

+ 269 - 0
Jellyfin.Api/Controllers/SearchController.cs

@@ -0,0 +1,269 @@
+using System;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Search;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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)
+        {
+            _searchEngine = searchEngine;
+            _libraryManager = libraryManager;
+            _dtoService = dtoService;
+            _imageProcessor = 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 delimeted.</param>
+        /// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimeted.</param>
+        /// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimeted.</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> Get(
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] Guid? userId,
+            [FromQuery, Required] string? searchTerm,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? 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 = RequestHelpers.Split(includeItemTypes, ',', true),
+                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+                ParentId = parentId,
+
+                IsKids = isKids,
+                IsMovie = isMovie,
+                IsNews = isNews,
+                IsSeries = isSeries,
+                IsSports = isSports
+            });
+
+            return new SearchHintResult
+            {
+                TotalRecordCount = result.TotalRecordCount,
+                SearchHints = result.Items.Select(GetSearchHintResult).ToArray()
+            };
+        }
+
+        /// <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;
+
+            var result = new SearchHint
+            {
+                Name = item.Name,
+                IndexNumber = item.IndexNumber,
+                ParentIndexNumber = item.ParentIndexNumber,
+                Id = item.Id,
+                Type = item.GetClientTypeName(),
+                MediaType = item.MediaType,
+                MatchedTerm = hintInfo.MatchedTerm,
+                RunTimeTicks = item.RunTimeTicks,
+                ProductionYear = item.ProductionYear,
+                ChannelId = item.ChannelId,
+                EndDate = item.EndDate
+            };
+
+            // legacy
+            result.ItemId = result.Id;
+
+            if (item.IsFolder)
+            {
+                result.IsFolder = true;
+            }
+
+            var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary);
+
+            if (primaryImageTag != null)
+            {
+                result.PrimaryImageTag = primaryImageTag;
+                result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item);
+            }
+
+            SetThumbImageInfo(result, item);
+            SetBackdropImageInfo(result, 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();
+                    }
+
+                    break;
+                case MusicAlbum album:
+                    result.Artists = album.Artists;
+                    result.AlbumArtist = album.AlbumArtist;
+                    break;
+                case Audio song:
+                    result.AlbumArtist = song.AlbumArtists?[0];
+                    result.Artists = song.Artists;
+
+                    MusicAlbum musicAlbum = song.AlbumEntity;
+
+                    if (musicAlbum != null)
+                    {
+                        result.Album = musicAlbum.Name;
+                        result.AlbumId = musicAlbum.Id;
+                    }
+                    else
+                    {
+                        result.Album = song.Album;
+                    }
+
+                    break;
+            }
+
+            if (!item.ChannelId.Equals(Guid.Empty))
+            {
+                var channel = _libraryManager.GetItemById(item.ChannelId);
+                result.ChannelName = channel?.Name;
+            }
+
+            return result;
+        }
+
+        private void SetThumbImageInfo(SearchHint hint, BaseItem item)
+        {
+            var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null;
+
+            if (itemWithImage == null && item is Episode)
+            {
+                itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb);
+            }
+
+            if (itemWithImage == null)
+            {
+                itemWithImage = GetParentWithImage<BaseItem>(item, ImageType.Thumb);
+            }
+
+            if (itemWithImage != null)
+            {
+                var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb);
+
+                if (tag != null)
+                {
+                    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);
+
+            if (itemWithImage != null)
+            {
+                var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop);
+
+                if (tag != null)
+                {
+                    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));
+        }
+    }
+}

+ 491 - 0
Jellyfin.Api/Controllers/SessionController.cs

@@ -0,0 +1,491 @@
+#pragma warning disable CA1801
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Session;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The session controller.
+    /// </summary>
+    [Route("")]
+    public class SessionController : BaseJellyfinApiController
+    {
+        private readonly ISessionManager _sessionManager;
+        private readonly IUserManager _userManager;
+        private readonly IAuthorizationContext _authContext;
+        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="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
+        public SessionController(
+            ISessionManager sessionManager,
+            IUserManager userManager,
+            IAuthorizationContext authContext,
+            IDeviceManager deviceManager)
+        {
+            _sessionManager = sessionManager;
+            _userManager = userManager;
+            _authContext = authContext;
+            _deviceManager = deviceManager;
+        }
+
+        /// <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)
+        {
+            var result = _sessionManager.Sessions;
+
+            if (!string.IsNullOrEmpty(deviceId))
+            {
+                result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase));
+            }
+
+            if (controllableByUserId.HasValue && !controllableByUserId.Equals(Guid.Empty))
+            {
+                result = result.Where(i => i.SupportsRemoteControl);
+
+                var user = _userManager.GetUserById(controllableByUserId.Value);
+
+                if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
+                {
+                    result = result.Where(i => i.UserId.Equals(Guid.Empty) || i.ContainsUser(controllableByUserId.Value));
+                }
+
+                if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl))
+                {
+                    result = result.Where(i => !i.UserId.Equals(Guid.Empty));
+                }
+
+                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 =>
+                {
+                    if (!string.IsNullOrWhiteSpace(i.DeviceId))
+                    {
+                        if (!_deviceManager.CanAccessDevice(user, i.DeviceId))
+                        {
+                            return false;
+                        }
+                    }
+
+                    return true;
+                });
+            }
+
+            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 ActionResult DisplayContent(
+            [FromRoute, Required] string? sessionId,
+            [FromQuery, Required] string? itemType,
+            [FromQuery, Required] string? itemId,
+            [FromQuery, Required] string? itemName)
+        {
+            var command = new BrowseRequest
+            {
+                ItemId = itemId,
+                ItemName = itemName,
+                ItemType = itemType
+            };
+
+            _sessionManager.SendBrowseCommand(
+                RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id,
+                sessionId,
+                command,
+                CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Instructs a session to play an item.
+        /// </summary>
+        /// <param name="sessionId">The session id.</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="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="playRequest">The <see cref="PlayRequest"/>.</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 ActionResult Play(
+            [FromRoute, Required] string? sessionId,
+            [FromQuery] Guid[] itemIds,
+            [FromQuery] long? startPositionTicks,
+            [FromQuery] PlayCommand playCommand,
+            [FromBody, Required] PlayRequest playRequest)
+        {
+            if (playRequest == null)
+            {
+                throw new ArgumentException("Request Body may not be null");
+            }
+
+            playRequest.ItemIds = itemIds;
+            playRequest.StartPositionTicks = startPositionTicks;
+            playRequest.PlayCommand = playCommand;
+
+            _sessionManager.SendPlayCommand(
+                RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id,
+                sessionId,
+                playRequest,
+                CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Issues a playstate command to a client.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="playstateRequest">The <see cref="PlaystateRequest"/>.</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 ActionResult SendPlaystateCommand(
+            [FromRoute, Required] string? sessionId,
+            [FromBody] PlaystateRequest playstateRequest)
+        {
+            _sessionManager.SendPlaystateCommand(
+                RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id,
+                sessionId,
+                playstateRequest,
+                CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <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 ActionResult SendSystemCommand(
+            [FromRoute, Required] string? sessionId,
+            [FromRoute, Required] string? command)
+        {
+            var name = command;
+            if (Enum.TryParse(name, true, out GeneralCommandType commandType))
+            {
+                name = commandType.ToString();
+            }
+
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+            var generalCommand = new GeneralCommand
+            {
+                Name = name,
+                ControllingUserId = currentSession.UserId
+            };
+
+            _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None);
+
+            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 ActionResult SendGeneralCommand(
+            [FromRoute, Required] string? sessionId,
+            [FromRoute, Required] string? command)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+
+            var generalCommand = new GeneralCommand
+            {
+                Name = command,
+                ControllingUserId = currentSession.UserId
+            };
+
+            _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None);
+
+            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 ActionResult SendFullGeneralCommand(
+            [FromRoute, Required] string? sessionId,
+            [FromBody, Required] GeneralCommand command)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+
+            if (command == null)
+            {
+                throw new ArgumentException("Request body may not be null");
+            }
+
+            command.ControllingUserId = currentSession.UserId;
+
+            _sessionManager.SendGeneralCommand(
+                currentSession.Id,
+                sessionId,
+                command,
+                CancellationToken.None);
+
+            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="text">The message test.</param>
+        /// <param name="header">The message header.</param>
+        /// <param name="timeoutMs">The message timeout. If omitted the user will have to confirm viewing the message.</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 ActionResult SendMessageCommand(
+            [FromRoute, Required] string? sessionId,
+            [FromQuery, Required] string? text,
+            [FromQuery, Required] string? header,
+            [FromQuery] long? timeoutMs)
+        {
+            var command = new MessageCommand
+            {
+                Header = string.IsNullOrEmpty(header) ? "Message from Server" : header,
+                TimeoutMs = timeoutMs,
+                Text = text
+            };
+
+            _sessionManager.SendMessageCommand(RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, sessionId, command, CancellationToken.None);
+
+            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] 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] string? sessionId,
+            [FromRoute] Guid userId)
+        {
+            _sessionManager.RemoveAdditionalUser(sessionId, userId);
+            return NoContent();
+        }
+
+        /// <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 ActionResult PostCapabilities(
+            [FromQuery, Required] string? id,
+            [FromQuery] string? playableMediaTypes,
+            [FromQuery] string? supportedCommands,
+            [FromQuery] bool supportsMediaControl = false,
+            [FromQuery] bool supportsSync = false,
+            [FromQuery] bool supportsPersistentIdentifier = true)
+        {
+            if (string.IsNullOrWhiteSpace(id))
+            {
+                id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            }
+
+            _sessionManager.ReportCapabilities(id, new ClientCapabilities
+            {
+                PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true),
+                SupportedCommands = RequestHelpers.Split(supportedCommands, ',', true),
+                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 ActionResult PostFullCapabilities(
+            [FromQuery, Required] string? id,
+            [FromBody, Required] ClientCapabilities capabilities)
+        {
+            if (string.IsNullOrWhiteSpace(id))
+            {
+                id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            }
+
+            _sessionManager.ReportCapabilities(id, capabilities);
+
+            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 ActionResult ReportViewing(
+            [FromQuery] string? sessionId,
+            [FromQuery] string? itemId)
+        {
+            string session = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+
+            _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 ActionResult ReportSessionEnded()
+        {
+            AuthorizationInfo auth = _authContext.GetAuthorizationInfo(Request);
+
+            _sessionManager.Logout(auth.Token);
+            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 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("Auto/PasswordResetProviders")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders()
+        {
+            return _userManager.GetPasswordResetProviders();
+        }
+    }
+}

+ 41 - 16
Jellyfin.Api/Controllers/StartupController.cs

@@ -5,6 +5,7 @@ using Jellyfin.Api.Models.StartupDtos;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Library;
 using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
 namespace Jellyfin.Api.Controllers
@@ -30,21 +31,27 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Api endpoint for completing the startup wizard.
+        /// Completes the startup wizard.
         /// </summary>
+        /// <response code="204">Startup wizard completed.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Complete")]
-        public void CompleteWizard()
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult CompleteWizard()
         {
             _config.Configuration.IsStartupWizardCompleted = true;
             _config.SaveConfiguration();
+            return NoContent();
         }
 
         /// <summary>
-        /// Endpoint for getting the initial startup wizard configuration.
+        /// Gets the initial startup wizard configuration.
         /// </summary>
-        /// <returns>The initial startup wizard configuration.</returns>
+        /// <response code="200">Initial startup wizard configuration retrieved.</response>
+        /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns>
         [HttpGet("Configuration")]
-        public StartupConfigurationDto GetStartupConfiguration()
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<StartupConfigurationDto> GetStartupConfiguration()
         {
             return new StartupConfigurationDto
             {
@@ -55,41 +62,52 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Endpoint for updating the initial startup wizard configuration.
+        /// Sets the initial startup wizard configuration.
         /// </summary>
         /// <param name="uiCulture">The UI language culture.</param>
         /// <param name="metadataCountryCode">The metadata country code.</param>
         /// <param name="preferredMetadataLanguage">The preferred language for metadata.</param>
+        /// <response code="204">Configuration saved.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Configuration")]
-        public void UpdateInitialConfiguration(
-            [FromForm] string uiCulture,
-            [FromForm] string metadataCountryCode,
-            [FromForm] string preferredMetadataLanguage)
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult UpdateInitialConfiguration(
+            [FromForm] string? uiCulture,
+            [FromForm] string? metadataCountryCode,
+            [FromForm] string? preferredMetadataLanguage)
         {
             _config.Configuration.UICulture = uiCulture;
             _config.Configuration.MetadataCountryCode = metadataCountryCode;
             _config.Configuration.PreferredMetadataLanguage = preferredMetadataLanguage;
             _config.SaveConfiguration();
+            return NoContent();
         }
 
         /// <summary>
-        /// Endpoint for (dis)allowing remote access and UPnP.
+        /// Sets remote access and UPnP.
         /// </summary>
         /// <param name="enableRemoteAccess">Enable remote access.</param>
         /// <param name="enableAutomaticPortMapping">Enable UPnP.</param>
+        /// <response code="204">Configuration saved.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("RemoteAccess")]
-        public void SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping)
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping)
         {
             _config.Configuration.EnableRemoteAccess = enableRemoteAccess;
             _config.Configuration.EnableUPnP = enableAutomaticPortMapping;
             _config.SaveConfiguration();
+            return NoContent();
         }
 
         /// <summary>
-        /// Endpoint for returning the first user.
+        /// Gets the first user.
         /// </summary>
+        /// <response code="200">Initial user retrieved.</response>
         /// <returns>The first user.</returns>
         [HttpGet("User")]
+        [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.
@@ -103,12 +121,17 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Endpoint for updating the user name and password.
+        /// Sets the user name and password.
         /// </summary>
         /// <param name="startupUserDto">The DTO containing username and password.</param>
-        /// <returns>The async task.</returns>
+        /// <response code="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")]
-        public async Task UpdateUser([FromForm] StartupUserDto startupUserDto)
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> UpdateStartupUser([FromForm] StartupUserDto startupUserDto)
         {
             var user = _userManager.Users.First();
 
@@ -120,6 +143,8 @@ namespace Jellyfin.Api.Controllers
             {
                 await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
             }
+
+            return NoContent();
         }
     }
 }

+ 277 - 0
Jellyfin.Api/Controllers/StudiosController.cs

@@ -0,0 +1,277 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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="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. 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 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. 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="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="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] double? minCommunityRating,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? searchTerm,
+            [FromQuery] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? genres,
+            [FromQuery] string? genreIds,
+            [FromQuery] string? officialRatings,
+            [FromQuery] string? tags,
+            [FromQuery] string? years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] string? person,
+            [FromQuery] string? personIds,
+            [FromQuery] string? personTypes,
+            [FromQuery] string? studios,
+            [FromQuery] string? studioIds,
+            [FromQuery] Guid? userId,
+            [FromQuery] string? nameStartsWithOrGreater,
+            [FromQuery] string? nameStartsWith,
+            [FromQuery] string? nameLessThan,
+            [FromQuery] bool? enableImages = true,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            User? user = null;
+            BaseItem parentItem;
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                user = _userManager.GetUserById(userId.Value);
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
+            }
+            else
+            {
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
+            }
+
+            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
+            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
+            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
+
+            var query = new InternalItemsQuery(user)
+            {
+                ExcludeItemTypes = excludeItemTypesArr,
+                IncludeItemTypes = includeItemTypesArr,
+                MediaTypes = mediaTypesArr,
+                StartIndex = startIndex,
+                Limit = limit,
+                IsFavorite = isFavorite,
+                NameLessThan = nameLessThan,
+                NameStartsWith = nameStartsWith,
+                NameStartsWithOrGreater = nameStartsWithOrGreater,
+                Tags = RequestHelpers.Split(tags, ',', true),
+                OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
+                Genres = RequestHelpers.Split(genres, ',', true),
+                GenreIds = RequestHelpers.GetGuids(genreIds),
+                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Person = person,
+                PersonIds = RequestHelpers.GetGuids(personIds),
+                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
+                Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+                MinCommunityRating = minCommunityRating,
+                DtoOptions = dtoOptions,
+                SearchTerm = searchTerm,
+                EnableTotalRecordCount = enableTotalRecordCount
+            };
+
+            if (!string.IsNullOrWhiteSpace(parentId))
+            {
+                if (parentItem is Folder)
+                {
+                    query.AncestorIds = new[] { new Guid(parentId) };
+                }
+                else
+                {
+                    query.ItemIds = new[] { new Guid(parentId) };
+                }
+            }
+
+            // Studios
+            if (!string.IsNullOrEmpty(studios))
+            {
+                query.StudioIds = studios.Split('|').Select(i =>
+                {
+                    try
+                    {
+                        return _libraryManager.GetStudio(i);
+                    }
+                    catch
+                    {
+                        return null;
+                    }
+                }).Where(i => i != null).Select(i => i!.Id)
+                    .ToArray();
+            }
+
+            foreach (var filter in RequestHelpers.GetFilters(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 = new QueryResult<(BaseItem, ItemCounts)>();
+            var dtos = result.Items.Select(i =>
+            {
+                var (baseItem, itemCounts) = i;
+                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+                if (!string.IsNullOrWhiteSpace(includeItemTypes))
+                {
+                    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;
+            });
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos.ToArray(),
+                TotalRecordCount = result.TotalRecordCount
+            };
+        }
+
+        /// <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] string name, [FromQuery] Guid? userId)
+        {
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            var item = _libraryManager.GetStudio(name);
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                var user = _userManager.GetUserById(userId.Value);
+
+                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+            }
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions);
+        }
+    }
+}

+ 350 - 0
Jellyfin.Api/Controllers/SubtitleController.cs

@@ -0,0 +1,350 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Providers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Subtitle controller.
+    /// </summary>
+    [Route("")]
+    public class SubtitleController : BaseJellyfinApiController
+    {
+        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 IAuthorizationContext _authContext;
+        private readonly ILogger<SubtitleController> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SubtitleController"/> class.
+        /// </summary>
+        /// <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="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
+        public SubtitleController(
+            ILibraryManager libraryManager,
+            ISubtitleManager subtitleManager,
+            ISubtitleEncoder subtitleEncoder,
+            IMediaSourceManager mediaSourceManager,
+            IProviderManager providerManager,
+            IFileSystem fileSystem,
+            IAuthorizationContext authContext,
+            ILogger<SubtitleController> logger)
+        {
+            _libraryManager = libraryManager;
+            _subtitleManager = subtitleManager;
+            _subtitleEncoder = subtitleEncoder;
+            _mediaSourceManager = mediaSourceManager;
+            _providerManager = providerManager;
+            _fileSystem = fileSystem;
+            _authContext = authContext;
+            _logger = logger;
+        }
+
+        /// <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] Guid itemId,
+            [FromRoute] int index)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            _subtitleManager.DeleteSubtitles(item, index);
+            return NoContent();
+        }
+
+        /// <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] Guid itemId,
+            [FromRoute, Required] string? language,
+            [FromQuery] bool? isPerfectMatch)
+        {
+            var video = (Video)_libraryManager.GetItemById(itemId);
+
+            return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, 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] Guid itemId,
+            [FromRoute, Required] string? subtitleId)
+        {
+            var video = (Video)_libraryManager.GetItemById(itemId);
+
+            try
+            {
+                await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None)
+                    .ConfigureAwait(false);
+
+                _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error downloading subtitles");
+            }
+
+            return NoContent();
+        }
+
+        /// <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)]
+        public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string? id)
+        {
+            var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false);
+
+            return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format));
+        }
+
+        /// <summary>
+        /// Gets subtitles in a specified format.
+        /// </summary>
+        /// <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">Optional. 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/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")]
+        [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}", Name = "GetSubtitle_2")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult> GetSubtitle(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] string? mediaSourceId,
+            [FromRoute, Required] int index,
+            [FromRoute, Required] string? format,
+            [FromQuery] long? endPositionTicks,
+            [FromQuery] bool copyTimestamps = false,
+            [FromQuery] bool addVttTimeMap = false,
+            [FromRoute] long startPositionTicks = 0)
+        {
+            if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
+            {
+                format = "json";
+            }
+
+            if (string.IsNullOrEmpty(format))
+            {
+                var item = (Video)_libraryManager.GetItemById(itemId);
+
+                var idString = itemId.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);
+
+                FileStream stream = new FileStream(subtitleStream.Path, FileMode.Open, FileAccess.Read);
+                return File(stream, MimeTypes.GetMimeType(subtitleStream.Path));
+            }
+
+            if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap)
+            {
+                await using Stream stream = await EncodeSubtitles(itemId, mediaSourceId, index, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false);
+                using var reader = new StreamReader(stream);
+
+                var text = await reader.ReadToEndAsync().ConfigureAwait(false);
+
+                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(
+                await EncodeSubtitles(
+                    itemId,
+                    mediaSourceId,
+                    index,
+                    format,
+                    startPositionTicks,
+                    endPositionTicks,
+                    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)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+        public async Task<ActionResult> GetSubtitlePlaylist(
+            [FromRoute] Guid itemId,
+            [FromRoute] int index,
+            [FromRoute] string? mediaSourceId,
+            [FromQuery, Required] int segmentLength)
+        {
+            var item = (Video)_libraryManager.GetItemById(itemId);
+
+            var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false);
+
+            var runtime = mediaSource.RunTimeTicks ?? -1;
+
+            if (runtime <= 0)
+            {
+                throw new ArgumentException("HLS Subtitles are not supported for this media.");
+            }
+
+            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)");
+            }
+
+            var builder = new StringBuilder();
+            builder.AppendLine("#EXTM3U")
+                .Append("#EXT-X-TARGETDURATION:")
+                .AppendLine(segmentLength.ToString(CultureInfo.InvariantCulture))
+                .AppendLine("#EXT-X-VERSION:3")
+                .AppendLine("#EXT-X-MEDIA-SEQUENCE:0")
+                .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
+
+            long positionTicks = 0;
+
+            var accessToken = _authContext.GetAuthorizationInfo(Request).Token;
+
+            while (positionTicks < runtime)
+            {
+                var remaining = runtime - positionTicks;
+                var lengthTicks = Math.Min(remaining, segmentLengthTicks);
+
+                builder.Append("#EXTINF:")
+                    .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture))
+                    .AppendLine(",");
+
+                var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks);
+
+                var url = string.Format(
+                    CultureInfo.CurrentCulture,
+                    "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}",
+                    positionTicks.ToString(CultureInfo.InvariantCulture),
+                    endPositionTicks.ToString(CultureInfo.InvariantCulture),
+                    accessToken);
+
+                builder.AppendLine(url);
+
+                positionTicks += segmentLengthTicks;
+            }
+
+            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)
+        {
+            var item = _libraryManager.GetItemById(id);
+
+            return _subtitleEncoder.GetSubtitles(
+                item,
+                mediaSourceId,
+                index,
+                format,
+                startPositionTicks,
+                endPositionTicks ?? 0,
+                copyTimestamps,
+                CancellationToken.None);
+        }
+    }
+}

+ 88 - 0
Jellyfin.Api/Controllers/SuggestionsController.cs

@@ -0,0 +1,88 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The suggestions controller.
+    /// </summary>
+    [Route("")]
+    public class SuggestionsController : BaseJellyfinApiController
+    {
+        private readonly IDtoService _dtoService;
+        private readonly IUserManager _userManager;
+        private readonly ILibraryManager _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] Guid userId,
+            [FromQuery] string? mediaType,
+            [FromQuery] string? type,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] bool enableTotalRecordCount = false)
+        {
+            var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null;
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
+            {
+                OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
+                MediaTypes = RequestHelpers.Split(mediaType!, ',', true),
+                IncludeItemTypes = RequestHelpers.Split(type!, ',', true),
+                IsVirtualItem = false,
+                StartIndex = startIndex,
+                Limit = limit,
+                DtoOptions = dtoOptions,
+                EnableTotalRecordCount = enableTotalRecordCount,
+                Recursive = true
+            });
+
+            var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = result.TotalRecordCount,
+                Items = dtoList
+            };
+        }
+    }
+}

+ 205 - 0
Jellyfin.Api/Controllers/SyncPlayController.cs

@@ -0,0 +1,205 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Threading;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The sync play controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class SyncPlayController : BaseJellyfinApiController
+    {
+        private readonly ISessionManager _sessionManager;
+        private readonly IAuthorizationContext _authorizationContext;
+        private readonly ISyncPlayManager _syncPlayManager;
+
+        /// <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="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param>
+        public SyncPlayController(
+            ISessionManager sessionManager,
+            IAuthorizationContext authorizationContext,
+            ISyncPlayManager syncPlayManager)
+        {
+            _sessionManager = sessionManager;
+            _authorizationContext = authorizationContext;
+            _syncPlayManager = syncPlayManager;
+        }
+
+        /// <summary>
+        /// Create a new SyncPlay group.
+        /// </summary>
+        /// <response code="204">New group created.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("New")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayCreateGroup()
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            _syncPlayManager.NewGroup(currentSession, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Join an existing SyncPlay group.
+        /// </summary>
+        /// <param name="groupId">The sync play group id.</param>
+        /// <response code="204">Group join successful.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("Join")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayJoinGroup([FromQuery, Required] Guid groupId)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+
+            var joinRequest = new JoinGroupRequest()
+            {
+                GroupId = groupId
+            };
+
+            _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, 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)]
+        public ActionResult SyncPlayLeaveGroup()
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Gets all SyncPlay groups.
+        /// </summary>
+        /// <param name="filterItemId">Optional. Filter by item id.</param>
+        /// <response code="200">Groups returned.</response>
+        /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns>
+        [HttpGet("List")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<GroupInfoView>> SyncPlayGetGroups([FromQuery] Guid? filterItemId)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            return Ok(_syncPlayManager.ListGroups(currentSession, filterItemId.HasValue ? filterItemId.Value : Guid.Empty));
+        }
+
+        /// <summary>
+        /// Request play in SyncPlay group.
+        /// </summary>
+        /// <response code="204">Play request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("Play")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayPlay()
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new PlaybackRequest()
+            {
+                Type = PlaybackRequestType.Play
+            };
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request pause in SyncPlay group.
+        /// </summary>
+        /// <response code="204">Pause request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("Pause")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayPause()
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new PlaybackRequest()
+            {
+                Type = PlaybackRequestType.Pause
+            };
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request seek in SyncPlay group.
+        /// </summary>
+        /// <param name="positionTicks">The playback position in ticks.</param>
+        /// <response code="204">Seek request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("Seek")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlaySeek([FromQuery] long positionTicks)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new PlaybackRequest()
+            {
+                Type = PlaybackRequestType.Seek,
+                PositionTicks = positionTicks
+            };
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request group wait in SyncPlay group while buffering.
+        /// </summary>
+        /// <param name="when">When the request has been made by the client.</param>
+        /// <param name="positionTicks">The playback position in ticks.</param>
+        /// <param name="bufferingDone">Whether the buffering is done.</param>
+        /// <response code="204">Buffering request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("Buffering")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayBuffering([FromQuery] DateTime when, [FromQuery] long positionTicks, [FromQuery] bool bufferingDone)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new PlaybackRequest()
+            {
+                Type = bufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer,
+                When = when,
+                PositionTicks = positionTicks
+            };
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Update session ping.
+        /// </summary>
+        /// <param name="ping">The ping.</param>
+        /// <response code="204">Ping updated.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("Ping")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayPing([FromQuery] double ping)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new PlaybackRequest()
+            {
+                Type = PlaybackRequestType.Ping,
+                Ping = Convert.ToInt64(ping)
+            };
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+    }
+}

+ 219 - 0
Jellyfin.Api/Controllers/SystemController.cs

@@ -0,0 +1,219 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.System;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+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>
+        /// 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;
+        }
+
+        /// <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 async Task<ActionResult<SystemInfo>> GetSystemInfo()
+        {
+            return await _appHost.GetSystemInfo(CancellationToken.None).ConfigureAwait(false);
+        }
+
+        /// <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 async Task<ActionResult<PublicSystemInfo>> GetPublicSystemInfo()
+        {
+            return await _appHost.GetPublicSystemInfo(CancellationToken.None).ConfigureAwait(false);
+        }
+
+        /// <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()
+        {
+            Task.Run(async () =>
+            {
+                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()
+        {
+            Task.Run(async () =>
+            {
+                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;
+
+            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;
+        }
+
+        /// <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
+            {
+                IsLocal = Request.HttpContext.Connection.LocalIpAddress.Equals(Request.HttpContext.Connection.RemoteIpAddress),
+                IsInNetwork = _network.IsInLocalNetwork(Request.HttpContext.Connection.RemoteIpAddress.ToString())
+            };
+        }
+
+        /// <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)]
+        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);
+            return File(stream, "text/plain");
+        }
+
+        /// <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)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo()
+        {
+            var result = _appHost.GetWakeOnLanInfo();
+            return Ok(result);
+        }
+    }
+}

+ 39 - 0
Jellyfin.Api/Controllers/TimeSyncController.cs

@@ -0,0 +1,39 @@
+using System;
+using System.Globalization;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The time sync controller.
+    /// </summary>
+    [Route("")]
+    public class TimeSyncController : BaseJellyfinApiController
+    {
+        /// <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.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo);
+
+            var response = new UtcTimeResponse();
+            response.RequestReceptionTime = requestReceptionTime;
+
+            // Important to keep the following two lines at the end
+            var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo);
+            response.ResponseTransmissionTime = responseTransmissionTime;
+
+            // Implementing NTP on such a high level results in this useless
+            // information being sent. On the other hand it enables future additions.
+            return response;
+        }
+    }
+}

+ 281 - 0
Jellyfin.Api/Controllers/TrailersController.cs

@@ -0,0 +1,281 @@
+using System;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The trailers controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class TrailersController : BaseJellyfinApiController
+    {
+        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;
+        }
+
+        /// <summary>
+        /// Finds movies and trailers similar to a given trailer.
+        /// </summary>
+        /// <param name="userId">The user id.</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 delimeted.</param>
+        /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</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="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</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 delimeted. 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 delimeted.</param>
+        /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. 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 delimeted. 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 delimeted.</param>
+        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
+        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
+        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</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 delimeted.</param>
+        /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
+        /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</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 delimeted.</param>
+        /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</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 delimeted.</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 delimeted.</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 delimeted.</param>
+        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</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] string? adjacentTo,
+            [FromQuery] int? parentIndexNumber,
+            [FromQuery] bool? hasParentalRating,
+            [FromQuery] bool? isHd,
+            [FromQuery] bool? is4K,
+            [FromQuery] string? locationTypes,
+            [FromQuery] string? 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] string? excludeItemIds,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] bool? recursive,
+            [FromQuery] string? searchTerm,
+            [FromQuery] string? sortOrder,
+            [FromQuery] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? imageTypes,
+            [FromQuery] string? sortBy,
+            [FromQuery] bool? isPlayed,
+            [FromQuery] string? genres,
+            [FromQuery] string? officialRatings,
+            [FromQuery] string? tags,
+            [FromQuery] string? years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] string? person,
+            [FromQuery] string? personIds,
+            [FromQuery] string? personTypes,
+            [FromQuery] string? studios,
+            [FromQuery] string? artists,
+            [FromQuery] string? excludeArtistIds,
+            [FromQuery] string? artistIds,
+            [FromQuery] string? albumArtistIds,
+            [FromQuery] string? contributingArtistIds,
+            [FromQuery] string? albums,
+            [FromQuery] string? albumIds,
+            [FromQuery] string? ids,
+            [FromQuery] string? 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] string? seriesStatus,
+            [FromQuery] string? nameStartsWithOrGreater,
+            [FromQuery] string? nameStartsWith,
+            [FromQuery] string? nameLessThan,
+            [FromQuery] string? studioIds,
+            [FromQuery] string? genreIds,
+            [FromQuery] bool enableTotalRecordCount = true,
+            [FromQuery] bool? enableImages = true)
+        {
+            var includeItemTypes = "Trailer";
+
+            return _itemsController
+                .GetItems(
+                    userId,
+                    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,
+                    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);
+        }
+    }
+}

+ 385 - 0
Jellyfin.Api/Controllers/TvShowsController.cs

@@ -0,0 +1,385 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.TV;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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="enableImges">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="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
+        [HttpGet("NextUp")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetNextUp(
+            [FromQuery, Required] Guid? userId,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] string? seriesId,
+            [FromQuery] string? parentId,
+            [FromQuery] bool? enableImges,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var options = new DtoOptions()
+                .AddItemFields(fields!)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!);
+
+            var result = _tvSeriesManager.GetNextUp(
+                new NextUpQuery
+                {
+                    Limit = limit,
+                    ParentId = parentId,
+                    SeriesId = seriesId,
+                    StartIndex = startIndex,
+                    UserId = userId ?? Guid.Empty,
+                    EnableTotalRecordCount = enableTotalRecordCount
+                },
+                options);
+
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = result.TotalRecordCount,
+                Items = 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. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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="enableImges">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, Required] Guid? userId,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] string? parentId,
+            [FromQuery] bool? enableImges,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] bool? enableUserData)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            var minPremiereDate = DateTime.Now.Date.ToUniversalTime().AddDays(-1);
+
+            var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+
+            var options = new DtoOptions()
+                .AddItemFields(fields!)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!);
+
+            var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user)
+            {
+                IncludeItemTypes = new[] { nameof(Episode) },
+                OrderBy = new[] { ItemSortBy.PremiereDate, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(),
+                MinPremiereDate = minPremiereDate,
+                StartIndex = startIndex,
+                Limit = limit,
+                ParentId = parentIdGuid,
+                Recursive = true,
+                DtoOptions = options
+            });
+
+            var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = itemsResult.Count,
+                Items = returnItems
+            };
+        }
+
+        /// <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 delimeted. 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 delimeted. 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] string? seriesId,
+            [FromQuery, Required] Guid? userId,
+            [FromQuery] string? fields,
+            [FromQuery] int? season,
+            [FromQuery] string? seasonId,
+            [FromQuery] bool? isMissing,
+            [FromQuery] string? adjacentTo,
+            [FromQuery] string? startItemId,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] bool? enableImages,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] string? sortBy)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            List<BaseItem> episodes;
+
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields!)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+
+            if (!string.IsNullOrWhiteSpace(seasonId)) // Season id was supplied. Get episodes by season id.
+            {
+                var item = _libraryManager.GetItemById(new Guid(seasonId));
+                if (!(item is Season seasonItem))
+                {
+                    return NotFound("No season exists with Id " + seasonId);
+                }
+
+                episodes = seasonItem.GetEpisodes(user, dtoOptions);
+            }
+            else if (season.HasValue) // Season number was supplied. Get episodes by season number
+            {
+                if (!(_libraryManager.GetItemById(seriesId) is Series series))
+                {
+                    return NotFound("Series not found");
+                }
+
+                var seasonItem = series
+                    .GetSeasons(user, dtoOptions)
+                    .FirstOrDefault(i => i.IndexNumber == season.Value);
+
+                episodes = seasonItem == 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 Series series))
+                {
+                    return NotFound("Series not found");
+                }
+
+                episodes = series.GetEpisodes(user, dtoOptions).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();
+            }
+
+            if (!string.IsNullOrWhiteSpace(startItemId))
+            {
+                episodes = episodes
+                    .SkipWhile(i => !string.Equals(i.Id.ToString("N", CultureInfo.InvariantCulture), startItemId, StringComparison.OrdinalIgnoreCase))
+                    .ToList();
+            }
+
+            // This must be the last filter
+            if (!string.IsNullOrEmpty(adjacentTo))
+            {
+                episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo).ToList();
+            }
+
+            if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase))
+            {
+                episodes.Shuffle();
+            }
+
+            var returnItems = episodes;
+
+            if (startIndex.HasValue || limit.HasValue)
+            {
+                returnItems = ApplyPaging(episodes, startIndex, limit).ToList();
+            }
+
+            var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = episodes.Count,
+                Items = dtos
+            };
+        }
+
+        /// <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 delimeted. 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] string? seriesId,
+            [FromQuery, Required] Guid? userId,
+            [FromQuery] string? fields,
+            [FromQuery] bool? isSpecialSeason,
+            [FromQuery] bool? isMissing,
+            [FromQuery] string? adjacentTo,
+            [FromQuery] bool? enableImages,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] bool? enableUserData)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            if (!(_libraryManager.GetItemById(seriesId) is Series series))
+            {
+                return NotFound("Series not found");
+            }
+
+            var seasons = series.GetItemList(new InternalItemsQuery(user)
+            {
+                IsMissing = isMissing,
+                IsSpecialSeason = isSpecialSeason,
+                AdjacentTo = adjacentTo
+            });
+
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+
+            var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = returnItems.Count,
+                Items = 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;
+        }
+    }
+}

+ 329 - 0
Jellyfin.Api/Controllers/UniversalAudioController.cs

@@ -0,0 +1,329 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.VideoDtos;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The universal audio controller.
+    /// </summary>
+    [Route("")]
+    public class UniversalAudioController : BaseJellyfinApiController
+    {
+        private readonly IAuthorizationContext _authorizationContext;
+        private readonly MediaInfoController _mediaInfoController;
+        private readonly DynamicHlsController _dynamicHlsController;
+        private readonly AudioController _audioController;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="UniversalAudioController"/> class.
+        /// </summary>
+        /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="mediaInfoController">Instance of the <see cref="MediaInfoController"/>.</param>
+        /// <param name="dynamicHlsController">Instance of the <see cref="DynamicHlsController"/>.</param>
+        /// <param name="audioController">Instance of the <see cref="AudioController"/>.</param>
+        public UniversalAudioController(
+            IAuthorizationContext authorizationContext,
+            MediaInfoController mediaInfoController,
+            DynamicHlsController dynamicHlsController,
+            AudioController audioController)
+        {
+            _authorizationContext = authorizationContext;
+            _mediaInfoController = mediaInfoController;
+            _dynamicHlsController = dynamicHlsController;
+            _audioController = audioController;
+        }
+
+        /// <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="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")]
+        [HttpGet("Audio/{itemId}/{universal=universal}.{container?}", Name = "GetUniversalAudioStream_2")]
+        [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")]
+        [HttpHead("Audio/{itemId}/{universal=universal}.{container?}", Name = "HeadUniversalAudioStream_2")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status302Found)]
+        public async Task<ActionResult> GetUniversalAudioStream(
+            [FromRoute] Guid itemId,
+            [FromRoute] string? container,
+            [FromQuery] string? mediaSourceId,
+            [FromQuery] string? deviceId,
+            [FromQuery] Guid? userId,
+            [FromQuery] string? audioCodec,
+            [FromQuery] int? maxAudioChannels,
+            [FromQuery] int? transcodingAudioChannels,
+            [FromQuery] long? maxStreamingBitrate,
+            [FromQuery] long? startTimeTicks,
+            [FromQuery] string? transcodingContainer,
+            [FromQuery] string? transcodingProtocol,
+            [FromQuery] int? maxAudioSampleRate,
+            [FromQuery] int? maxAudioBitDepth,
+            [FromQuery] bool? enableRemoteMedia,
+            [FromQuery] bool breakOnNonKeyFrames,
+            [FromQuery] bool enableRedirection = true)
+        {
+            bool isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
+            var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels);
+            _authorizationContext.GetAuthorizationInfo(Request).DeviceId = deviceId;
+
+            var playbackInfoResult = await _mediaInfoController.GetPostedPlaybackInfo(
+                itemId,
+                userId,
+                maxStreamingBitrate,
+                startTimeTicks,
+                null,
+                null,
+                maxAudioChannels,
+                mediaSourceId,
+                null,
+                new DeviceProfileDto { DeviceProfile = deviceProfile })
+                .ConfigureAwait(false);
+            var mediaSource = playbackInfoResult.Value.MediaSources[0];
+
+            if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http)
+            {
+                if (enableRedirection)
+                {
+                    if (mediaSource.IsRemote && enableRemoteMedia.HasValue && enableRemoteMedia.Value)
+                    {
+                        return Redirect(mediaSource.Path);
+                    }
+                }
+            }
+
+            var isStatic = mediaSource.SupportsDirectStream;
+            if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
+            {
+                var transcodingProfile = deviceProfile.TranscodingProfiles[0];
+
+                // hls segment container can only be mpegts or fmp4 per ffmpeg documentation
+                // TODO: remove this when we switch back to the segment muxer
+                var supportedHlsContainers = new[] { "mpegts", "fmp4" };
+
+                if (isHeadRequest)
+                {
+                    _dynamicHlsController.Request.Method = HttpMethod.Head.Method;
+                }
+
+                return await _dynamicHlsController.GetMasterHlsAudioPlaylist(
+                    itemId,
+                    ".m3u8",
+                    isStatic,
+                    null,
+                    null,
+                    null,
+                    playbackInfoResult.Value.PlaySessionId,
+                    // fallback to mpegts if device reports some weird value unsupported by hls
+                    Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "mpegts",
+                    null,
+                    null,
+                    mediaSource.Id,
+                    deviceId,
+                    transcodingProfile.AudioCodec,
+                    null,
+                    null,
+                    null,
+                    transcodingProfile.BreakOnNonKeyFrames,
+                    maxAudioSampleRate,
+                    maxAudioBitDepth,
+                    null,
+                    isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)),
+                    maxAudioChannels,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    startTimeTicks,
+                    null,
+                    null,
+                    null,
+                    null,
+                    SubtitleDeliveryMethod.Hls,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()),
+                    null,
+                    null,
+                    EncodingContext.Static,
+                    new Dictionary<string, string>())
+                    .ConfigureAwait(false);
+            }
+            else
+            {
+                if (isHeadRequest)
+                {
+                    _audioController.Request.Method = HttpMethod.Head.Method;
+                }
+
+                return await _audioController.GetAudioStream(
+                    itemId,
+                    isStatic ? null : ("." + mediaSource.TranscodingContainer),
+                    isStatic,
+                    null,
+                    null,
+                    null,
+                    playbackInfoResult.Value.PlaySessionId,
+                    null,
+                    null,
+                    null,
+                    mediaSource.Id,
+                    deviceId,
+                    audioCodec,
+                    null,
+                    null,
+                    null,
+                    breakOnNonKeyFrames,
+                    maxAudioSampleRate,
+                    maxAudioBitDepth,
+                    isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)),
+                    null,
+                    maxAudioChannels,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    startTimeTicks,
+                    null,
+                    null,
+                    null,
+                    null,
+                    SubtitleDeliveryMethod.Embed,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()),
+                    null,
+                    null,
+                    null,
+                    null)
+                    .ConfigureAwait(false);
+            }
+        }
+
+        private DeviceProfile GetDeviceProfile(
+            string? container,
+            string? transcodingContainer,
+            string? audioCodec,
+            string? transcodingProtocol,
+            bool? breakOnNonKeyFrames,
+            int? transcodingAudioChannels,
+            int? maxAudioSampleRate,
+            int? maxAudioBitDepth,
+            int? maxAudioChannels)
+        {
+            var deviceProfile = new DeviceProfile();
+
+            var directPlayProfiles = new List<DirectPlayProfile>();
+
+            var containers = RequestHelpers.Split(container, ',', true);
+
+            foreach (var cont in containers)
+            {
+                var parts = RequestHelpers.Split(cont, ',', true);
+
+                var audioCodecs = parts.Length == 1 ? null : string.Join(",", parts.Skip(1).ToArray());
+
+                directPlayProfiles.Add(new DirectPlayProfile { Type = DlnaProfileType.Audio, Container = parts[0], AudioCodec = audioCodecs });
+            }
+
+            deviceProfile.DirectPlayProfiles = directPlayProfiles.ToArray();
+
+            deviceProfile.TranscodingProfiles = new[]
+            {
+                new TranscodingProfile
+                {
+                    Type = DlnaProfileType.Audio,
+                    Context = EncodingContext.Streaming,
+                    Container = transcodingContainer,
+                    AudioCodec = audioCodec,
+                    Protocol = transcodingProtocol,
+                    BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+                    MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture)
+                }
+            };
+
+            var codecProfiles = new List<CodecProfile>();
+            var conditions = new List<ProfileCondition>();
+
+            if (maxAudioSampleRate.HasValue)
+            {
+                // codec profile
+                conditions.Add(new ProfileCondition { Condition = ProfileConditionType.LessThanEqual, IsRequired = false, Property = ProfileConditionValue.AudioSampleRate, Value = maxAudioSampleRate.Value.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) });
+            }
+
+            if (maxAudioChannels.HasValue)
+            {
+                // codec profile
+                conditions.Add(new ProfileCondition { Condition = ProfileConditionType.LessThanEqual, IsRequired = false, Property = ProfileConditionValue.AudioChannels, Value = maxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) });
+            }
+
+            if (conditions.Count > 0)
+            {
+                // codec profile
+                codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = container, Conditions = conditions.ToArray() });
+            }
+
+            deviceProfile.CodecProfiles = codecProfiles.ToArray();
+
+            return deviceProfile;
+        }
+    }
+}

+ 541 - 0
Jellyfin.Api/Controllers/UserController.cs

@@ -0,0 +1,541 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.UserDtos;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Users;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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;
+
+        /// <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>
+        public UserController(
+            IUserManager userManager,
+            ISessionManager sessionManager,
+            INetworkManager networkManager,
+            IDeviceManager deviceManager,
+            IAuthorizationContext authContext,
+            IServerConfigurationManager config)
+        {
+            _userManager = userManager;
+            _sessionManager = sessionManager;
+            _networkManager = networkManager;
+            _deviceManager = deviceManager;
+            _authContext = authContext;
+            _config = config;
+        }
+
+        /// <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>
+        /// 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));
+            }
+
+            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] Guid userId)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            if (user == null)
+            {
+                return NotFound("User not found");
+            }
+
+            var result = _userManager.GetUserDto(user, HttpContext.Connection.RemoteIpAddress.ToString());
+            return result;
+        }
+
+        /// <summary>
+        /// Deletes a user.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <response code="200">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 ActionResult DeleteUser([FromRoute] Guid userId)
+        {
+            var user = _userManager.GetUserById(userId);
+            _sessionManager.RevokeUserTokens(user.Id, null);
+            _userManager.DeleteUser(userId);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Authenticates a user.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="pw">The password as plain text.</param>
+        /// <param name="password">The password sha1-hash.</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)]
+        public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
+            [FromRoute, Required] Guid userId,
+            [FromQuery, Required] string? pw,
+            [FromQuery] string? password)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            if (user == null)
+            {
+                return NotFound("User not found");
+            }
+
+            if (!string.IsNullOrEmpty(password) && string.IsNullOrEmpty(pw))
+            {
+                return Forbid("Only sha1 password is not allowed.");
+            }
+
+            // Password should always be null
+            AuthenticateUserByName request = new AuthenticateUserByName
+            {
+                Username = user.Username,
+                Password = null,
+                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 = _authContext.GetAuthorizationInfo(Request);
+
+            try
+            {
+                var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest
+                {
+                    App = auth.Client,
+                    AppVersion = auth.Version,
+                    DeviceId = auth.DeviceId,
+                    DeviceName = auth.Device,
+                    Password = request.Pw,
+                    PasswordSha1 = request.Password,
+                    RemoteEndPoint = HttpContext.Connection.RemoteIpAddress.ToString(),
+                    Username = request.Username
+                }).ConfigureAwait(false);
+
+                return result;
+            }
+            catch (SecurityException e)
+            {
+                // rethrow adding IP address to message
+                throw new SecurityException($"[{HttpContext.Connection.RemoteIpAddress}] {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="200">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] Guid userId,
+            [FromBody] UpdateUserPassword request)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+            {
+                return Forbid("User is not allowed to update the password.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+
+            if (user == null)
+            {
+                return NotFound("User not found");
+            }
+
+            if (request.ResetPassword)
+            {
+                await _userManager.ResetPassword(user).ConfigureAwait(false);
+            }
+            else
+            {
+                var success = await _userManager.AuthenticateUser(
+                    user.Username,
+                    request.CurrentPw,
+                    request.CurrentPw,
+                    HttpContext.Connection.RemoteIpAddress.ToString(),
+                    false).ConfigureAwait(false);
+
+                if (success == null)
+                {
+                    return Forbid("Invalid user or password entered.");
+                }
+
+                await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false);
+
+                var currentToken = _authContext.GetAuthorizationInfo(Request).Token;
+
+                _sessionManager.RevokeUserTokens(user.Id, currentToken);
+            }
+
+            return NoContent();
+        }
+
+        /// <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="200">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 ActionResult UpdateUserEasyPassword(
+            [FromRoute] Guid userId,
+            [FromBody] UpdateUserEasyPassword request)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+            {
+                return Forbid("User is not allowed to update the easy password.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+
+            if (user == null)
+            {
+                return NotFound("User not found");
+            }
+
+            if (request.ResetPassword)
+            {
+                _userManager.ResetEasyPassword(user);
+            }
+            else
+            {
+                _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword);
+            }
+
+            return NoContent();
+        }
+
+        /// <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] Guid userId,
+            [FromBody] UserDto updateUser)
+        {
+            if (updateUser == null)
+            {
+                return BadRequest();
+            }
+
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false))
+            {
+                return Forbid("User update not allowed.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+
+            if (string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
+            {
+                await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+                _userManager.UpdateConfiguration(user.Id, updateUser.Configuration);
+            }
+            else
+            {
+                await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
+                _userManager.UpdateConfiguration(updateUser.Id, updateUser.Configuration);
+            }
+
+            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 ActionResult UpdateUserPolicy(
+            [FromRoute] Guid userId,
+            [FromBody] UserPolicy newPolicy)
+        {
+            if (newPolicy == null)
+            {
+                return BadRequest();
+            }
+
+            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 Forbid("There must be at least one user in the system with administrative access.");
+                }
+            }
+
+            // If disabling
+            if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator))
+            {
+                return Forbid("Administrators cannot be disabled.");
+            }
+
+            // If disabling
+            if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
+            {
+                if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
+                {
+                    return Forbid("There must be at least one enabled user in the system.");
+                }
+
+                var currentToken = _authContext.GetAuthorizationInfo(Request).Token;
+                _sessionManager.RevokeUserTokens(user.Id, currentToken);
+            }
+
+            _userManager.UpdatePolicy(userId, newPolicy);
+
+            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 ActionResult UpdateUserConfiguration(
+            [FromRoute] Guid userId,
+            [FromBody] UserConfiguration userConfig)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false))
+            {
+                return Forbid("User configuration update not allowed");
+            }
+
+            _userManager.UpdateConfiguration(userId, userConfig);
+
+            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] CreateUserByName request)
+        {
+            var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false);
+
+            // no need to authenticate password for new user
+            if (request.Password != null)
+            {
+                await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false);
+            }
+
+            var result = _userManager.GetUserDto(newUser, HttpContext.Connection.RemoteIpAddress.ToString());
+
+            return result;
+        }
+
+        /// <summary>
+        /// Initiates the forgot password process for a local user.
+        /// </summary>
+        /// <param name="enteredUsername">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] string? enteredUsername)
+        {
+            var isLocal = HttpContext.Connection.RemoteIpAddress.Equals(HttpContext.Connection.LocalIpAddress)
+                          || _networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString());
+
+            var result = await _userManager.StartForgotPasswordProcess(enteredUsername, isLocal).ConfigureAwait(false);
+
+            return result;
+        }
+
+        /// <summary>
+        /// Redeems a forgot password pin.
+        /// </summary>
+        /// <param name="pin">The 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] string? pin)
+        {
+            var result = await _userManager.RedeemPasswordResetPin(pin).ConfigureAwait(false);
+            return result;
+        }
+
+        private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
+        {
+            var users = _userManager.Users;
+
+            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 (filterByDevice)
+            {
+                var deviceId = _authContext.GetAuthorizationInfo(Request).DeviceId;
+
+                if (!string.IsNullOrWhiteSpace(deviceId))
+                {
+                    users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId));
+                }
+            }
+
+            if (filterByNetwork)
+            {
+                if (!_networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString()))
+                {
+                    users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess));
+                }
+            }
+
+            var result = users
+                .OrderBy(u => u.Username)
+                .Select(i => _userManager.GetUserDto(i, HttpContext.Connection.RemoteIpAddress.ToString()));
+
+            return result;
+        }
+    }
+}

+ 392 - 0
Jellyfin.Api/Controllers/UserLibraryController.cs

@@ -0,0 +1,392 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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;
+
+        /// <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>
+        public UserLibraryController(
+            IUserManager userManager,
+            IUserDataManager userDataRepository,
+            ILibraryManager libraryManager,
+            IDtoService dtoService,
+            IUserViewManager userViewManager,
+            IFileSystem fileSystem)
+        {
+            _userManager = userManager;
+            _userDataRepository = userDataRepository;
+            _libraryManager = libraryManager;
+            _dtoService = dtoService;
+            _userViewManager = userViewManager;
+            _fileSystem = fileSystem;
+        }
+
+        /// <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] Guid userId, [FromRoute] Guid itemId)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            var item = itemId.Equals(Guid.Empty)
+                ? _libraryManager.GetUserRootFolder()
+                : _libraryManager.GetItemById(itemId);
+
+            await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions, 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] Guid userId)
+        {
+            var user = _userManager.GetUserById(userId);
+            var item = _libraryManager.GetUserRootFolder();
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            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] Guid userId, [FromRoute] Guid itemId)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            var item = itemId.Equals(Guid.Empty)
+                ? _libraryManager.GetUserRootFolder()
+                : _libraryManager.GetItemById(itemId);
+
+            var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false);
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray();
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos,
+                TotalRecordCount = dtos.Length
+            };
+        }
+
+        /// <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] Guid userId, [FromRoute] Guid itemId)
+        {
+            return MarkFavorite(userId, itemId, true);
+        }
+
+        /// <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] Guid userId, [FromRoute] Guid itemId)
+        {
+            return MarkFavorite(userId, itemId, false);
+        }
+
+        /// <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] Guid userId, [FromRoute] Guid itemId)
+        {
+            return UpdateUserItemRatingInternal(userId, itemId, null);
+        }
+
+        /// <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] Guid userId, [FromRoute] Guid itemId, [FromQuery] bool? likes)
+        {
+            return UpdateUserItemRatingInternal(userId, itemId, likes);
+        }
+
+        /// <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] Guid userId, [FromRoute] Guid itemId)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            var item = itemId.Equals(Guid.Empty)
+                ? _libraryManager.GetUserRootFolder()
+                : _libraryManager.GetItemById(itemId);
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var dtosExtras = item.GetExtras(new[] { ExtraType.Trailer })
+                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
+                .ToArray();
+
+            if (item is IHasTrailers hasTrailers)
+            {
+                var trailers = hasTrailers.GetTrailers();
+                var dtosTrailers = _dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item);
+                var allTrailers = new BaseItemDto[dtosExtras.Length + dtosTrailers.Count];
+                dtosExtras.CopyTo(allTrailers, 0);
+                dtosTrailers.CopyTo(allTrailers, dtosExtras.Length);
+                return allTrailers;
+            }
+
+            return dtosExtras;
+        }
+
+        /// <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] Guid userId, [FromRoute] Guid itemId)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            var item = itemId.Equals(Guid.Empty)
+                ? _libraryManager.GetUserRootFolder()
+                : _libraryManager.GetItemById(itemId);
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            return Ok(item
+                .GetExtras(BaseItem.DisplayExtraTypes)
+                .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. This allows multiple, comma delimeted. Options: Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, SortName, Studios, Taglines.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</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] Guid userId,
+            [FromQuery] Guid? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] bool? isPlayed,
+            [FromQuery] bool? enableImages,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int limit = 20,
+            [FromQuery] bool groupItems = true)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            if (!isPlayed.HasValue)
+            {
+                if (user.HidePlayedInLatest)
+                {
+                    isPlayed = false;
+                }
+            }
+
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            var list = _userViewManager.GetLatestItems(
+                new LatestItemsQuery
+                {
+                    GroupItems = groupItems,
+                    IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                    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 != null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum))
+                {
+                    item = i.Item1;
+                    childCount = i.Item2.Count;
+                }
+
+                var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user);
+
+                dto.ChildCount = childCount;
+
+                return dto;
+            });
+
+            return Ok(dtos);
+        }
+
+        private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
+        {
+            if (item is Person)
+            {
+                var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
+                var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3;
+
+                if (!hasMetdata)
+                {
+                    var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+                    {
+                        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(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
+
+            // Get the user data for this item
+            var data = _userDataRepository.GetUserData(user, item);
+
+            // Set favorite status
+            data.IsFavorite = isFavorite;
+
+            _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
+
+            return _userDataRepository.GetUserDataDto(item, user);
+        }
+
+        /// <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);
+
+            var item = itemId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
+
+            // Get the user data for this item
+            var data = _userDataRepository.GetUserData(user, item);
+
+            data.Likes = likes;
+
+            _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
+
+            return _userDataRepository.GetUserDataDto(item, user);
+        }
+    }
+}

+ 149 - 0
Jellyfin.Api/Controllers/UserViewsController.cs

@@ -0,0 +1,149 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.UserViewDtos;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Library;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// User views controller.
+    /// </summary>
+    [Route("")]
+    public class UserViewsController : BaseJellyfinApiController
+    {
+        private readonly IUserManager _userManager;
+        private readonly IUserViewManager _userViewManager;
+        private readonly IDtoService _dtoService;
+        private readonly IAuthorizationContext _authContext;
+        private readonly ILibraryManager _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="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        public UserViewsController(
+            IUserManager userManager,
+            IUserViewManager userViewManager,
+            IDtoService dtoService,
+            IAuthorizationContext authContext,
+            ILibraryManager libraryManager)
+        {
+            _userManager = userManager;
+            _userViewManager = userViewManager;
+            _dtoService = dtoService;
+            _authContext = authContext;
+            _libraryManager = 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 ActionResult<QueryResult<BaseItemDto>> GetUserViews(
+            [FromRoute] Guid userId,
+            [FromQuery] bool? includeExternalContent,
+            [FromQuery] string? presetViews,
+            [FromQuery] bool includeHidden = false)
+        {
+            var query = new UserViewQuery
+            {
+                UserId = userId,
+                IncludeHidden = includeHidden
+            };
+
+            if (includeExternalContent.HasValue)
+            {
+                query.IncludeExternalContent = includeExternalContent.Value;
+            }
+
+            if (!string.IsNullOrWhiteSpace(presetViews))
+            {
+                query.PresetViews = RequestHelpers.Split(presetViews, ',', true);
+            }
+
+            var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty;
+            if (app.IndexOf("emby rt", StringComparison.OrdinalIgnoreCase) != -1)
+            {
+                query.PresetViews = new[] { CollectionType.Movies, CollectionType.TvShows };
+            }
+
+            var folders = _userViewManager.GetUserViews(query);
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var fields = dtoOptions.Fields.ToList();
+
+            fields.Add(ItemFields.PrimaryImageAspectRatio);
+            fields.Add(ItemFields.DisplayPreferencesId);
+            fields.Remove(ItemFields.BasicSyncInfo);
+            dtoOptions.Fields = fields.ToArray();
+
+            var user = _userManager.GetUserById(userId);
+
+            var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user))
+                .ToArray();
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos,
+                TotalRecordCount = dtos.Length
+            };
+        }
+
+        /// <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] Guid userId)
+        {
+            var user = _userManager.GetUserById(userId);
+            if (user == null)
+            {
+                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));
+        }
+    }
+}

+ 81 - 0
Jellyfin.Api/Controllers/VideoAttachmentsController.cs

@@ -0,0 +1,81 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Net.Mime;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Attachments controller.
+    /// </summary>
+    [Route("Videos")]
+    public class VideoAttachmentsController : BaseJellyfinApiController
+    {
+        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;
+        }
+
+        /// <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}")]
+        [Produces(MediaTypeNames.Application.Octet)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult<FileStreamResult>> GetAttachment(
+            [FromRoute, Required] Guid videoId,
+            [FromRoute, Required] string mediaSourceId,
+            [FromRoute, Required] int index)
+        {
+            try
+            {
+                var item = _libraryManager.GetItemById(videoId);
+                if (item == null)
+                {
+                    return NotFound();
+                }
+
+                var (attachment, stream) = await _attachmentExtractor.GetAttachment(
+                        item,
+                        mediaSourceId,
+                        index,
+                        CancellationToken.None)
+                    .ConfigureAwait(false);
+
+                var contentType = string.IsNullOrWhiteSpace(attachment.MimeType)
+                    ? MediaTypeNames.Application.Octet
+                    : attachment.MimeType;
+
+                return new FileStreamResult(stream, contentType);
+            }
+            catch (ResourceNotFoundException e)
+            {
+                return NotFound(e.Message);
+            }
+        }
+    }
+}

+ 503 - 0
Jellyfin.Api/Controllers/VideoHlsController.cs

@@ -0,0 +1,503 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.PlaybackDtos;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Dlna;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The video hls controller.
+    /// </summary>
+    [Route("")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class VideoHlsController : BaseJellyfinApiController
+    {
+        private const string DefaultEncoderPreset = "superfast";
+        private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
+
+        private readonly EncodingHelper _encodingHelper;
+        private readonly IDlnaManager _dlnaManager;
+        private readonly IAuthorizationContext _authContext;
+        private readonly IUserManager _userManager;
+        private readonly ILibraryManager _libraryManager;
+        private readonly IMediaSourceManager _mediaSourceManager;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+        private readonly IMediaEncoder _mediaEncoder;
+        private readonly IFileSystem _fileSystem;
+        private readonly ISubtitleEncoder _subtitleEncoder;
+        private readonly IConfiguration _configuration;
+        private readonly IDeviceManager _deviceManager;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+        private readonly ILogger<VideoHlsController> _logger;
+        private readonly EncodingOptions _encodingOptions;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="VideoHlsController"/> class.
+        /// </summary>
+        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param>
+        /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param>
+        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+        /// <param name="userManger">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> 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="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+        /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param>
+        /// <param name="logger">Instance of the <see cref="ILogger{VideoHlsController}"/>.</param>
+        public VideoHlsController(
+            IMediaEncoder mediaEncoder,
+            IFileSystem fileSystem,
+            ISubtitleEncoder subtitleEncoder,
+            IConfiguration configuration,
+            IDlnaManager dlnaManager,
+            IUserManager userManger,
+            IAuthorizationContext authorizationContext,
+            ILibraryManager libraryManager,
+            IMediaSourceManager mediaSourceManager,
+            IServerConfigurationManager serverConfigurationManager,
+            IDeviceManager deviceManager,
+            TranscodingJobHelper transcodingJobHelper,
+            ILogger<VideoHlsController> logger)
+        {
+            _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
+
+            _dlnaManager = dlnaManager;
+            _authContext = authorizationContext;
+            _userManager = userManger;
+            _libraryManager = libraryManager;
+            _mediaSourceManager = mediaSourceManager;
+            _serverConfigurationManager = serverConfigurationManager;
+            _mediaEncoder = mediaEncoder;
+            _fileSystem = fileSystem;
+            _subtitleEncoder = subtitleEncoder;
+            _configuration = configuration;
+            _deviceManager = deviceManager;
+            _transcodingJobHelper = transcodingJobHelper;
+            _logger = logger;
+            _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 lenght.</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, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">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)]
+        public async Task<ActionResult> GetLiveHlsStream(
+            [FromRoute] 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? transcodingReasons,
+            [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
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static ?? true,
+                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 ?? true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc ?? true,
+                DeInterlace = deInterlace ?? true,
+                RequireNonAnamorphic = requireNonAnamorphic ?? true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context,
+                StreamOptions = streamOptions,
+                MaxHeight = maxHeight,
+                MaxWidth = maxWidth,
+                EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true
+            };
+
+            var cancellationTokenSource = new CancellationTokenSource();
+            using var state = await StreamingHelpers.GetStreamingState(
+                    streamingRequest,
+                    Request,
+                    _authContext,
+                    _mediaSourceManager,
+                    _userManager,
+                    _libraryManager,
+                    _serverConfigurationManager,
+                    _mediaEncoder,
+                    _fileSystem,
+                    _subtitleEncoder,
+                    _configuration,
+                    _dlnaManager,
+                    _deviceManager,
+                    _transcodingJobHelper,
+                    TranscodingJobType,
+                    cancellationTokenSource.Token)
+                .ConfigureAwait(false);
+
+            TranscodingJobDto? job = null;
+            var playlist = state.OutputFilePath;
+
+            if (!System.IO.File.Exists(playlist))
+            {
+                var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlist);
+                await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
+                try
+                {
+                    if (!System.IO.File.Exists(playlist))
+                    {
+                        // If the playlist doesn't already exist, startup ffmpeg
+                        try
+                        {
+                            job = await _transcodingJobHelper.StartFfMpeg(
+                                    state,
+                                    playlist,
+                                    GetCommandLineArguments(playlist, state),
+                                    Request,
+                                    TranscodingJobType,
+                                    cancellationTokenSource)
+                                .ConfigureAwait(false);
+                            job.IsLiveOutput = true;
+                        }
+                        catch
+                        {
+                            state.Dispose();
+                            throw;
+                        }
+
+                        minSegments = state.MinSegments;
+                        if (minSegments > 0)
+                        {
+                            await HlsHelpers.WaitForMinimumSegmentCount(playlist, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false);
+                        }
+                    }
+                }
+                finally
+                {
+                    transcodingLock.Release();
+                }
+            }
+
+            job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlist, TranscodingJobType);
+
+            if (job != null)
+            {
+                _transcodingJobHelper.OnTranscodeEndRequest(job);
+            }
+
+            var playlistText = HlsHelpers.GetLivePlaylistText(playlist, state.SegmentLength);
+
+            return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8"));
+        }
+
+        /// <summary>
+        /// Gets the command line arguments for ffmpeg.
+        /// </summary>
+        /// <param name="outputPath">The output path of the file.</param>
+        /// <param name="state">The <see cref="StreamState"/>.</param>
+        /// <returns>The command line arguments as a string.</returns>
+        private string GetCommandLineArguments(string outputPath, StreamState state)
+        {
+            var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+            var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
+            var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
+            var format = !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) ? "." + state.Request.SegmentContainer : ".ts";
+            var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + format;
+
+            var segmentFormat = format.TrimStart('.');
+            if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
+            {
+                segmentFormat = "mpegts";
+            }
+
+            var baseUrlParam = string.Format(
+                CultureInfo.InvariantCulture,
+                "\"hls{0}\"",
+                Path.GetFileNameWithoutExtension(outputPath));
+
+            return string.Format(
+                    CultureInfo.InvariantCulture,
+                    "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {7} -individual_header_trailer 0 -segment_format {8} -segment_list_entry_prefix {9} -segment_list_type m3u8 -segment_start_number 0 -segment_list \"{10}\" -y \"{11}\"",
+                    inputModifier,
+                    _encodingHelper.GetInputArgument(state, _encodingOptions),
+                    threads,
+                    _encodingHelper.GetMapArgs(state),
+                    GetVideoArguments(state),
+                    GetAudioArguments(state),
+                    state.SegmentLength.ToString(CultureInfo.InvariantCulture),
+                    string.Empty,
+                    segmentFormat,
+                    baseUrlParam,
+                    outputPath,
+                    outputTsArg)
+                .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)
+        {
+            var codec = _encodingHelper.GetAudioEncoder(state);
+
+            if (EncodingHelper.IsCopyCodec(codec))
+            {
+                return "-codec:a:0 copy";
+            }
+
+            var args = "-codec:a:0 " + codec;
+
+            var channels = state.OutputAudioChannels;
+
+            if (channels.HasValue)
+            {
+                args += " -ac " + channels.Value;
+            }
+
+            var bitrate = state.OutputAudioBitrate;
+
+            if (bitrate.HasValue)
+            {
+                args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture);
+            }
+
+            if (state.OutputAudioSampleRate.HasValue)
+            {
+                args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
+            }
+
+            args += " " + _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
+
+            return args;
+        }
+
+        /// <summary>
+        /// Gets the video arguments for transcoding.
+        /// </summary>
+        /// <param name="state">The <see cref="StreamState"/>.</param>
+        /// <returns>The command line arguments for video transcoding.</returns>
+        private string GetVideoArguments(StreamState state)
+        {
+            if (!state.IsOutputVideo)
+            {
+                return string.Empty;
+            }
+
+            var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+
+            var args = "-codec:v:0 " + codec;
+
+            // if (state.EnableMpegtsM2TsMode)
+            // {
+            //     args += " -mpegts_m2ts_mode 1";
+            // }
+
+            // See if we can save come cpu cycles by avoiding encoding
+            if (codec.Equals("copy", StringComparison.OrdinalIgnoreCase))
+            {
+                // if h264_mp4toannexb is ever added, do not use it for live tv
+                if (state.VideoStream != null &&
+                    !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
+                {
+                    string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream);
+                    if (!string.IsNullOrEmpty(bitStreamArgs))
+                    {
+                        args += " " + bitStreamArgs;
+                    }
+                }
+            }
+            else
+            {
+                var keyFrameArg = string.Format(
+                    CultureInfo.InvariantCulture,
+                    " -force_key_frames \"expr:gte(t,n_forced*{0})\"",
+                    state.SegmentLength.ToString(CultureInfo.InvariantCulture));
+
+                var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
+
+                args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset) + keyFrameArg;
+
+                // Add resolution params, if specified
+                if (!hasGraphicalSubs)
+                {
+                    args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
+                }
+
+                // This is for internal graphical subs
+                if (hasGraphicalSubs)
+                {
+                    args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
+                }
+            }
+
+            args += " -flags -global_header";
+
+            if (!string.IsNullOrEmpty(state.OutputVideoSync))
+            {
+                args += " -vsync " + state.OutputVideoSync;
+            }
+
+            args += _encodingHelper.GetOutputFFlags(state);
+
+            return args;
+        }
+    }
+}

+ 523 - 0
Jellyfin.Api/Controllers/VideosController.cs

@@ -0,0 +1,523 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Dlna;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+
+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 IAuthorizationContext _authContext;
+        private readonly IMediaSourceManager _mediaSourceManager;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+        private readonly IMediaEncoder _mediaEncoder;
+        private readonly IFileSystem _fileSystem;
+        private readonly ISubtitleEncoder _subtitleEncoder;
+        private readonly IConfiguration _configuration;
+        private readonly IDeviceManager _deviceManager;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+        private readonly IHttpClientFactory _httpClientFactory;
+
+        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="authContext">Instance of the <see cref="IAuthorizationContext"/> 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="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param>
+        /// <param name="configuration">Instance of the <see cref="IConfiguration"/> 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>
+        public VideosController(
+            ILibraryManager libraryManager,
+            IUserManager userManager,
+            IDtoService dtoService,
+            IDlnaManager dlnaManager,
+            IAuthorizationContext authContext,
+            IMediaSourceManager mediaSourceManager,
+            IServerConfigurationManager serverConfigurationManager,
+            IMediaEncoder mediaEncoder,
+            IFileSystem fileSystem,
+            ISubtitleEncoder subtitleEncoder,
+            IConfiguration configuration,
+            IDeviceManager deviceManager,
+            TranscodingJobHelper transcodingJobHelper,
+            IHttpClientFactory httpClientFactory)
+        {
+            _libraryManager = libraryManager;
+            _userManager = userManager;
+            _dtoService = dtoService;
+            _dlnaManager = dlnaManager;
+            _authContext = authContext;
+            _mediaSourceManager = mediaSourceManager;
+            _serverConfigurationManager = serverConfigurationManager;
+            _mediaEncoder = mediaEncoder;
+            _fileSystem = fileSystem;
+            _subtitleEncoder = subtitleEncoder;
+            _configuration = configuration;
+            _deviceManager = deviceManager;
+            _transcodingJobHelper = transcodingJobHelper;
+            _httpClientFactory = httpClientFactory;
+        }
+
+        /// <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] Guid itemId, [FromQuery] Guid? userId)
+        {
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+
+            var item = itemId.Equals(Guid.Empty)
+                ? (!userId.Equals(Guid.Empty)
+                    ? _libraryManager.GetUserRootFolder()
+                    : _libraryManager.RootFolder)
+                : _libraryManager.GetItemById(itemId);
+
+            var dtoOptions = new DtoOptions();
+            dtoOptions = dtoOptions.AddClientFields(Request);
+
+            BaseItemDto[] items;
+            if (item is Video video)
+            {
+                items = video.GetAdditionalParts()
+                    .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video))
+                    .ToArray();
+            }
+            else
+            {
+                items = Array.Empty<BaseItemDto>();
+            }
+
+            var result = new QueryResult<BaseItemDto>
+            {
+                Items = items,
+                TotalRecordCount = items.Length
+            };
+
+            return result;
+        }
+
+        /// <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.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult DeleteAlternateSources([FromRoute] Guid itemId)
+        {
+            var video = (Video)_libraryManager.GetItemById(itemId);
+
+            if (video == 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>();
+
+                link.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
+            }
+
+            video.LinkedAlternateVersions = Array.Empty<LinkedChild>();
+            video.SetPrimaryVersionId(null);
+            video.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Merges videos into a single record.
+        /// </summary>
+        /// <param name="itemIds">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 ActionResult MergeVersions([FromQuery, Required] string? itemIds)
+        {
+            var items = RequestHelpers.Split(itemIds, ',', true)
+                .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 videosWithVersions = items.Where(i => i.MediaSourceCount > 1).ToList();
+
+            var primaryVersion = videosWithVersions.FirstOrDefault();
+            if (primaryVersion == null)
+            {
+                primaryVersion = items
+                    .OrderBy(i =>
+                    {
+                        if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile)
+                        {
+                            return 1;
+                        }
+
+                        return 0;
+                    })
+                    .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0)
+                    .First();
+            }
+
+            var list = primaryVersion.LinkedAlternateVersions.ToList();
+
+            foreach (var item in items.Where(i => i.Id != primaryVersion.Id))
+            {
+                item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture));
+
+                item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
+
+                list.Add(new LinkedChild
+                {
+                    Path = item.Path,
+                    ItemId = item.Id
+                });
+
+                foreach (var linkedItem in item.LinkedAlternateVersions)
+                {
+                    if (!list.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase)))
+                    {
+                        list.Add(linkedItem);
+                    }
+                }
+
+                if (item.LinkedAlternateVersions.Length > 0)
+                {
+                    item.LinkedAlternateVersions = Array.Empty<LinkedChild>();
+                    item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
+                }
+            }
+
+            primaryVersion.LinkedAlternateVersions = list.ToArray();
+            primaryVersion.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
+            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 lenght.</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, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">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=stream}.{container?}", Name = "GetVideoStream_2")]
+        [HttpGet("{itemId}/stream")]
+        [HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadVideoStream_2")]
+        [HttpHead("{itemId}/stream", Name = "HeadVideoStream")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult> GetVideoStream(
+            [FromRoute] Guid itemId,
+            [FromRoute] 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? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext context,
+            [FromQuery] Dictionary<string, string> streamOptions)
+        {
+            var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
+            var cancellationTokenSource = new CancellationTokenSource();
+            var streamingRequest = new VideoRequestDto
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static ?? true,
+                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 ?? true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc ?? true,
+                DeInterlace = deInterlace ?? true,
+                RequireNonAnamorphic = requireNonAnamorphic ?? true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context,
+                StreamOptions = streamOptions
+            };
+
+            using var state = await StreamingHelpers.GetStreamingState(
+                    streamingRequest,
+                    Request,
+                    _authContext,
+                    _mediaSourceManager,
+                    _userManager,
+                    _libraryManager,
+                    _serverConfigurationManager,
+                    _mediaEncoder,
+                    _fileSystem,
+                    _subtitleEncoder,
+                    _configuration,
+                    _dlnaManager,
+                    _deviceManager,
+                    _transcodingJobHelper,
+                    _transcodingJobType,
+                    cancellationTokenSource.Token)
+                .ConfigureAwait(false);
+
+            if (@static.HasValue && @static.Value && state.DirectStreamProvider != null)
+            {
+                StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager);
+
+                await new ProgressiveFileCopier(state.DirectStreamProvider, null, _transcodingJobHelper, CancellationToken.None)
+                    {
+                        AllowEndOfFile = false
+                    }.WriteToAsync(Response.Body, CancellationToken.None)
+                    .ConfigureAwait(false);
+
+                // TODO (moved from MediaBrowser.Api): Don't hardcode contentType
+                return File(Response.Body, MimeTypes.GetMimeType("file.ts")!);
+            }
+
+            // Static remote stream
+            if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http)
+            {
+                StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager);
+
+                using var httpClient = _httpClientFactory.CreateClient();
+                return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, this, httpClient).ConfigureAwait(false);
+            }
+
+            if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File)
+            {
+                return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically");
+            }
+
+            var outputPath = state.OutputFilePath;
+            var outputPathExists = System.IO.File.Exists(outputPath);
+
+            var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
+            var isTranscodeCached = outputPathExists && transcodingJob != null;
+
+            StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, startTimeTicks, Request, _dlnaManager);
+
+            // Static stream
+            if (@static.HasValue && @static.Value)
+            {
+                var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
+
+                if (state.MediaSource.IsInfiniteStream)
+                {
+                    await new ProgressiveFileCopier(state.MediaPath, null, _transcodingJobHelper, CancellationToken.None)
+                        {
+                            AllowEndOfFile = false
+                        }.WriteToAsync(Response.Body, CancellationToken.None)
+                        .ConfigureAwait(false);
+
+                    return File(Response.Body, contentType);
+                }
+
+                return FileStreamResponseHelpers.GetStaticFileResult(
+                    state.MediaPath,
+                    contentType,
+                    isHeadRequest,
+                    this);
+            }
+
+            // Need to start ffmpeg (because media can't be returned directly)
+            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+            var encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
+            var ffmpegCommandLineArguments = encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast");
+            return await FileStreamResponseHelpers.GetTranscodedFile(
+                state,
+                isHeadRequest,
+                this,
+                _transcodingJobHelper,
+                ffmpegCommandLineArguments,
+                Request,
+                _transcodingJobType,
+                cancellationTokenSource).ConfigureAwait(false);
+        }
+    }
+}

+ 234 - 0
Jellyfin.Api/Controllers/YearsController.cs

@@ -0,0 +1,234 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+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>
+        /// 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;
+        }
+
+        /// <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. 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 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] string? sortOrder,
+            [FromQuery] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? sortBy,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] Guid? userId,
+            [FromQuery] bool recursive = true,
+            [FromQuery] bool? enableImages = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            User? user = null;
+            BaseItem parentItem;
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                user = _userManager.GetUserById(userId.Value);
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
+            }
+            else
+            {
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
+            }
+
+            IList<BaseItem> items;
+
+            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
+            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
+            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
+
+            var query = new InternalItemsQuery(user)
+            {
+                ExcludeItemTypes = excludeItemTypesArr,
+                IncludeItemTypes = includeItemTypesArr,
+                MediaTypes = mediaTypesArr,
+                DtoOptions = dtoOptions
+            };
+
+            bool Filter(BaseItem i) => FilterItem(i, excludeItemTypesArr, includeItemTypesArr, mediaTypesArr);
+
+            if (parentItem.IsFolder)
+            {
+                var folder = (Folder)parentItem;
+
+                if (!userId.Equals(Guid.Empty))
+                {
+                    items = recursive ? folder.GetRecursiveChildren(user, query).ToList() : folder.GetChildren(user, true).Where(Filter).ToList();
+                }
+                else
+                {
+                    items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList();
+                }
+            }
+            else
+            {
+                items = new[] { parentItem }.Where(Filter).ToList();
+            }
+
+            var extractedItems = GetAllItems(items);
+
+            var filteredItems = _libraryManager.Sort(extractedItems, user, RequestHelpers.GetOrderBy(sortBy, sortOrder));
+
+            var ibnItemsArray = filteredItems.ToList();
+
+            IEnumerable<BaseItem> ibnItems = ibnItemsArray;
+
+            var result = new QueryResult<BaseItemDto> { TotalRecordCount = ibnItemsArray.Count };
+
+            if (startIndex.HasValue || limit.HasValue)
+            {
+                if (startIndex.HasValue)
+                {
+                    ibnItems = ibnItems.Skip(startIndex.Value);
+                }
+
+                if (limit.HasValue)
+                {
+                    ibnItems = ibnItems.Take(limit.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));
+
+            result.Items = dtos.Where(i => i != 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] int year, [FromQuery] Guid? userId)
+        {
+            var item = _libraryManager.GetYear(year);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            var dtoOptions = new DtoOptions()
+                .AddClientFields(Request);
+
+            if (userId.HasValue && !userId.Equals(Guid.Empty))
+            {
+                var user = _userManager.GetUserById(userId.Value);
+                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+            }
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions);
+        }
+
+        private bool FilterItem(BaseItem f, IReadOnlyCollection<string> excludeItemTypes, IReadOnlyCollection<string> includeItemTypes, IReadOnlyCollection<string> mediaTypes)
+        {
+            // Exclude item types
+            if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase))
+            {
+                return false;
+            }
+
+            // Include item types
+            if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase))
+            {
+                return false;
+            }
+
+            // Include MediaTypes
+            if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparer.OrdinalIgnoreCase))
+            {
+                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));
+        }
+    }
+}

+ 162 - 0
Jellyfin.Api/Extensions/DtoExtensions.cs

@@ -0,0 +1,162 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Extensions
+{
+    /// <summary>
+    /// Dto Extensions.
+    /// </summary>
+    public static class DtoExtensions
+    {
+        /// <summary>
+        /// Add Dto Item fields.
+        /// </summary>
+        /// <remarks>
+        /// Converted from IHasItemFields.
+        /// Legacy order: 1.
+        /// </remarks>
+        /// <param name="dtoOptions">DtoOptions object.</param>
+        /// <param name="fields">Comma delimited string of fields.</param>
+        /// <returns>Modified DtoOptions object.</returns>
+        internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string? fields)
+        {
+            if (string.IsNullOrEmpty(fields))
+            {
+                dtoOptions.Fields = Array.Empty<ItemFields>();
+            }
+            else
+            {
+                dtoOptions.Fields = fields.Split(',')
+                    .Select(v =>
+                    {
+                        if (Enum.TryParse(v, true, out ItemFields value))
+                        {
+                            return (ItemFields?)value;
+                        }
+
+                        return null;
+                    })
+                    .Where(i => i.HasValue)
+                    .Select(i => i!.Value)
+                    .ToArray();
+            }
+
+            return dtoOptions;
+        }
+
+        /// <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="request">Current request.</param>
+        /// <returns>Modified DtoOptions object.</returns>
+        internal static DtoOptions AddClientFields(
+            this DtoOptions dtoOptions, HttpRequest request)
+        {
+            dtoOptions.Fields ??= Array.Empty<ItemFields>();
+
+            string? client = ClaimHelpers.GetClient(request.HttpContext.User);
+
+            // No client in claim
+            if (string.IsNullOrEmpty(client))
+            {
+                return dtoOptions;
+            }
+
+            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)
+                {
+                    int oldLen = dtoOptions.Fields.Length;
+                    var arr = new ItemFields[oldLen + 1];
+                    dtoOptions.Fields.CopyTo(arr, 0);
+                    arr[oldLen] = ItemFields.RecursiveItemCount;
+                    dtoOptions.Fields = arr;
+                }
+            }
+
+            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)
+                {
+                    int oldLen = dtoOptions.Fields.Length;
+                    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,
+            string? enableImageTypes)
+        {
+            dtoOptions.EnableImages = enableImages ?? true;
+
+            if (imageTypeLimit.HasValue)
+            {
+                dtoOptions.ImageTypeLimit = imageTypeLimit.Value;
+            }
+
+            if (enableUserData.HasValue)
+            {
+                dtoOptions.EnableUserData = enableUserData.Value;
+            }
+
+            if (!string.IsNullOrWhiteSpace(enableImageTypes))
+            {
+                dtoOptions.ImageTypes = enableImageTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
+                    .Select(v => (ImageType)Enum.Parse(typeof(ImageType), v, true))
+                    .ToArray();
+            }
+
+            return dtoOptions;
+        }
+
+        /// <summary>
+        /// Check if DtoOptions contains field.
+        /// </summary>
+        /// <param name="dtoOptions">DtoOptions object.</param>
+        /// <param name="field">Field to check.</param>
+        /// <returns>Field existence.</returns>
+        internal static bool ContainsField(this DtoOptions dtoOptions, ItemFields field)
+            => dtoOptions.Fields != null && dtoOptions.Fields.Contains(field);
+    }
+}

+ 75 - 0
Jellyfin.Api/Helpers/ClaimHelpers.cs

@@ -0,0 +1,75 @@
+using System;
+using System.Linq;
+using System.Security.Claims;
+using Jellyfin.Api.Constants;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// Claim Helpers.
+    /// </summary>
+    public static class ClaimHelpers
+    {
+        /// <summary>
+        /// Get user id from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>User id.</returns>
+        public static Guid? GetUserId(in ClaimsPrincipal user)
+        {
+            var value = GetClaimValue(user, InternalClaimTypes.UserId);
+            return string.IsNullOrEmpty(value)
+                ? null
+                : (Guid?)Guid.Parse(value);
+        }
+
+        /// <summary>
+        /// Get device id from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>Device id.</returns>
+        public static string? GetDeviceId(in ClaimsPrincipal user)
+            => GetClaimValue(user, InternalClaimTypes.DeviceId);
+
+        /// <summary>
+        /// Get device from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>Device.</returns>
+        public static string? GetDevice(in ClaimsPrincipal user)
+            => GetClaimValue(user, InternalClaimTypes.Device);
+
+        /// <summary>
+        /// Get client from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>Client.</returns>
+        public static string? GetClient(in ClaimsPrincipal user)
+            => GetClaimValue(user, InternalClaimTypes.Client);
+
+        /// <summary>
+        /// Get version from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>Version.</returns>
+        public static string? GetVersion(in ClaimsPrincipal user)
+            => GetClaimValue(user, InternalClaimTypes.Version);
+
+        /// <summary>
+        /// Get token from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>Token.</returns>
+        public static string? GetToken(in ClaimsPrincipal user)
+            => GetClaimValue(user, InternalClaimTypes.Token);
+
+        private static string? GetClaimValue(in ClaimsPrincipal user, string name)
+        {
+            return user?.Identities
+                .SelectMany(c => c.Claims)
+                .Where(claim => claim.Type.Equals(name, StringComparison.OrdinalIgnoreCase))
+                .Select(claim => claim.Value)
+                .FirstOrDefault();
+        }
+    }
+}

+ 138 - 0
Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs

@@ -0,0 +1,138 @@
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.PlaybackDtos;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Controller.MediaEncoding;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// The stream response helpers.
+    /// </summary>
+    public static class FileStreamResponseHelpers
+    {
+        /// <summary>
+        /// Returns a static file from a remote source.
+        /// </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="controller">The <see cref="ControllerBase"/> managing the response.</param>
+        /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param>
+        /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns>
+        public static async Task<ActionResult> GetStaticRemoteStreamResult(
+            StreamState state,
+            bool isHeadRequest,
+            ControllerBase controller,
+            HttpClient httpClient)
+        {
+            if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
+            {
+                httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent);
+            }
+
+            using var response = await httpClient.GetAsync(state.MediaPath).ConfigureAwait(false);
+            var contentType = response.Content.Headers.ContentType.ToString();
+
+            controller.Response.Headers[HeaderNames.AcceptRanges] = "none";
+
+            if (isHeadRequest)
+            {
+                return controller.File(Array.Empty<byte>(), contentType);
+            }
+
+            return controller.File(await response.Content.ReadAsStreamAsync().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>
+        /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
+        /// <param name="controller">The <see cref="ControllerBase"/> managing the response.</param>
+        /// <returns>An <see cref="ActionResult"/> the file.</returns>
+        public static ActionResult GetStaticFileResult(
+            string path,
+            string contentType,
+            bool isHeadRequest,
+            ControllerBase controller)
+        {
+            controller.Response.ContentType = contentType;
+
+            // if the request is a head request, return a NoContent result with the same headers as it would with a GET request
+            if (isHeadRequest)
+            {
+                return controller.NoContent();
+            }
+
+            return controller.PhysicalFile(path, contentType);
+        }
+
+        /// <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="controller">The <see cref="ControllerBase"/> managing the response.</param>
+        /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param>
+        /// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param>
+        /// <param name="request">The <see cref="HttpRequest"/> starting the transcoding.</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,
+            ControllerBase controller,
+            TranscodingJobHelper transcodingJobHelper,
+            string ffmpegCommandLineArguments,
+            HttpRequest request,
+            TranscodingJobType transcodingJobType,
+            CancellationTokenSource cancellationTokenSource)
+        {
+            // Use the command line args with a dummy playlist path
+            var outputPath = state.OutputFilePath;
+
+            controller.Response.Headers[HeaderNames.AcceptRanges] = "none";
+
+            var contentType = state.GetMimeType(outputPath);
+
+            // Headers only
+            if (isHeadRequest)
+            {
+                return controller.File(Array.Empty<byte>(), contentType);
+            }
+
+            var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath);
+            await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
+            try
+            {
+                TranscodingJobDto? job;
+                if (!File.Exists(outputPath))
+                {
+                    job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false);
+                }
+                else
+                {
+                    job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive);
+                    state.Dispose();
+                }
+
+                var memoryStream = new MemoryStream();
+                await new ProgressiveFileCopier(outputPath, job, transcodingJobHelper, CancellationToken.None).WriteToAsync(memoryStream, CancellationToken.None).ConfigureAwait(false);
+                memoryStream.Position = 0;
+                return controller.File(memoryStream, contentType);
+            }
+            finally
+            {
+                transcodingLock.Release();
+            }
+        }
+    }
+}

+ 6 - 7
MediaBrowser.Api/Playback/Hls/HlsCodecStringFactory.cs → Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs

@@ -1,15 +1,14 @@
-using System;
+using System;
+using System.Globalization;
 using System.Text;
 
-
-namespace MediaBrowser.Api.Playback
+namespace Jellyfin.Api.Helpers
 {
     /// <summary>
-    /// Get various codec strings for use in HLS playlists.
+    /// Hls Codec string helpers.
     /// </summary>
-    static class HlsCodecStringFactory
+    public static class HlsCodecStringHelpers
     {
-
         /// <summary>
         /// Gets a MP3 codec string.
         /// </summary>
@@ -69,7 +68,7 @@ namespace MediaBrowser.Api.Playback
                 result.Append(".4240");
             }
 
-            string levelHex = level.ToString("X2");
+            string levelHex = level.ToString("X2", CultureInfo.InvariantCulture);
             result.Append(levelHex);
 
             return result.ToString();

+ 95 - 0
Jellyfin.Api/Helpers/HlsHelpers.cs

@@ -0,0 +1,95 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// The hls helpers.
+    /// </summary>
+    public static class HlsHelpers
+    {
+        /// <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);
+
+            while (!cancellationToken.IsCancellationRequested)
+            {
+                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.SequentialScan);
+                    await using (fileStream.ConfigureAwait(false))
+                    {
+                        using var reader = new StreamReader(fileStream);
+                        var count = 0;
+
+                        while (!reader.EndOfStream)
+                        {
+                            var line = await reader.ReadLineAsync().ConfigureAwait(false);
+
+                            if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1)
+                            {
+                                count++;
+                                if (count >= segmentCount)
+                                {
+                                    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);
+            }
+        }
+
+        /// <summary>
+        /// Gets the hls playlist text.
+        /// </summary>
+        /// <param name="path">The path to the playlist file.</param>
+        /// <param name="segmentLength">The segment length.</param>
+        /// <returns>The playlist text as a string.</returns>
+        public static string GetLivePlaylistText(string path, int segmentLength)
+        {
+            using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+            using var reader = new StreamReader(stream);
+
+            var text = reader.ReadToEnd();
+
+            text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture);
+
+            var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture);
+
+            text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
+            // text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
+
+            return text;
+        }
+    }
+}

+ 175 - 0
Jellyfin.Api/Helpers/ProgressiveFileCopier.cs

@@ -0,0 +1,175 @@
+using System;
+using System.Buffers;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.PlaybackDtos;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.IO;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// Progressive file copier.
+    /// </summary>
+    public class ProgressiveFileCopier
+    {
+        private readonly TranscodingJobDto? _job;
+        private readonly string? _path;
+        private readonly CancellationToken _cancellationToken;
+        private readonly IDirectStreamProvider? _directStreamProvider;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+        private long _bytesWritten;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ProgressiveFileCopier"/> class.
+        /// </summary>
+        /// <param name="path">The path to copy from.</param>
+        /// <param name="job">The transcoding job.</param>
+        /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/>.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        public ProgressiveFileCopier(string path, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, CancellationToken cancellationToken)
+        {
+            _path = path;
+            _job = job;
+            _cancellationToken = cancellationToken;
+            _transcodingJobHelper = transcodingJobHelper;
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ProgressiveFileCopier"/> class.
+        /// </summary>
+        /// <param name="directStreamProvider">Instance of the <see cref="IDirectStreamProvider"/> interface.</param>
+        /// <param name="job">The transcoding job.</param>
+        /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/>.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        public ProgressiveFileCopier(IDirectStreamProvider directStreamProvider, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, CancellationToken cancellationToken)
+        {
+            _directStreamProvider = directStreamProvider;
+            _job = job;
+            _cancellationToken = cancellationToken;
+            _transcodingJobHelper = transcodingJobHelper;
+        }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether allow read end of file.
+        /// </summary>
+        public bool AllowEndOfFile { get; set; } = true;
+
+        /// <summary>
+        /// Gets or sets copy start position.
+        /// </summary>
+        public long StartPosition { get; set; }
+
+        /// <summary>
+        /// Write source stream to output.
+        /// </summary>
+        /// <param name="outputStream">Output stream.</param>
+        /// <param name="cancellationToken">Cancellation token.</param>
+        /// <returns>A <see cref="Task"/>.</returns>
+        public async Task WriteToAsync(Stream outputStream, CancellationToken cancellationToken)
+        {
+            cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken).Token;
+
+            try
+            {
+                if (_directStreamProvider != null)
+                {
+                    await _directStreamProvider.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false);
+                    return;
+                }
+
+                var fileOptions = FileOptions.SequentialScan;
+                var allowAsyncFileRead = false;
+
+                // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039
+                if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+                {
+                    fileOptions |= FileOptions.Asynchronous;
+                    allowAsyncFileRead = true;
+                }
+
+                await using var inputStream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions);
+
+                var eofCount = 0;
+                const int EmptyReadLimit = 20;
+                if (StartPosition > 0)
+                {
+                    inputStream.Position = StartPosition;
+                }
+
+                while (eofCount < EmptyReadLimit || !AllowEndOfFile)
+                {
+                    var bytesRead = await CopyToInternalAsync(inputStream, outputStream, allowAsyncFileRead, cancellationToken).ConfigureAwait(false);
+
+                    if (bytesRead == 0)
+                    {
+                        if (_job == null || _job.HasExited)
+                        {
+                            eofCount++;
+                        }
+
+                        await Task.Delay(100, cancellationToken).ConfigureAwait(false);
+                    }
+                    else
+                    {
+                        eofCount = 0;
+                    }
+                }
+            }
+            finally
+            {
+                if (_job != null)
+                {
+                    _transcodingJobHelper.OnTranscodeEndRequest(_job);
+                }
+            }
+        }
+
+        private async Task<int> CopyToInternalAsync(Stream source, Stream destination, bool readAsync, CancellationToken cancellationToken)
+        {
+            var array = ArrayPool<byte>.Shared.Rent(IODefaults.CopyToBufferSize);
+            int bytesRead;
+            int totalBytesRead = 0;
+
+            if (readAsync)
+            {
+                bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false);
+            }
+            else
+            {
+                bytesRead = source.Read(array, 0, array.Length);
+            }
+
+            while (bytesRead != 0)
+            {
+                var bytesToWrite = bytesRead;
+
+                if (bytesToWrite > 0)
+                {
+                    await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false);
+
+                    _bytesWritten += bytesRead;
+                    totalBytesRead += bytesRead;
+
+                    if (_job != null)
+                    {
+                        _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten);
+                    }
+                }
+
+                if (readAsync)
+                {
+                    bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false);
+                }
+                else
+                {
+                    bytesRead = source.Read(array, 0, array.Length);
+                }
+            }
+
+            return totalBytesRead;
+        }
+    }
+}

+ 181 - 0
Jellyfin.Api/Helpers/RequestHelpers.cs

@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// Request Extensions.
+    /// </summary>
+    public static class RequestHelpers
+    {
+        /// <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 ValueTuple<string, SortOrder>[] GetOrderBy(string? sortBy, string? requestedSortOrder)
+        {
+            var val = sortBy;
+
+            if (string.IsNullOrEmpty(val))
+            {
+                return Array.Empty<ValueTuple<string, SortOrder>>();
+            }
+
+            var vals = val.Split(',');
+            if (string.IsNullOrWhiteSpace(requestedSortOrder))
+            {
+                requestedSortOrder = "Ascending";
+            }
+
+            var sortOrders = requestedSortOrder.Split(',');
+
+            var result = new ValueTuple<string, SortOrder>[vals.Length];
+
+            for (var i = 0; i < vals.Length; i++)
+            {
+                var sortOrderIndex = sortOrders.Length > i ? i : 0;
+
+                var sortOrderValue = sortOrders.Length > sortOrderIndex ? sortOrders[sortOrderIndex] : null;
+                var sortOrder = string.Equals(sortOrderValue, "Descending", StringComparison.OrdinalIgnoreCase)
+                    ? SortOrder.Descending
+                    : SortOrder.Ascending;
+
+                result[i] = new ValueTuple<string, SortOrder>(vals[i], sortOrder);
+            }
+
+            return result;
+        }
+
+        /// <summary>
+        /// Get parsed filters.
+        /// </summary>
+        /// <param name="filters">The filters.</param>
+        /// <returns>Item filters.</returns>
+        public static IEnumerable<ItemFilter> GetFilters(string? filters)
+        {
+            return string.IsNullOrEmpty(filters)
+                ? Array.Empty<ItemFilter>()
+                : filters.Split(',').Select(v => Enum.Parse<ItemFilter>(v, true));
+        }
+
+        /// <summary>
+        /// Splits a string at a separating character into an array of substrings.
+        /// </summary>
+        /// <param name="value">The string to split.</param>
+        /// <param name="separator">The char that separates the substrings.</param>
+        /// <param name="removeEmpty">Option to remove empty substrings from the array.</param>
+        /// <returns>An array of the substrings.</returns>
+        internal static string[] Split(string? value, char separator, bool removeEmpty)
+        {
+            if (string.IsNullOrWhiteSpace(value))
+            {
+                return Array.Empty<string>();
+            }
+
+            return removeEmpty
+                ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries)
+                : value.Split(separator);
+        }
+
+        /// <summary>
+        /// Checks if the user can update an entry.
+        /// </summary>
+        /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="requestContext">The <see cref="HttpRequest"/>.</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(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences)
+        {
+            var auth = authContext.GetAuthorizationInfo(requestContext);
+
+            var authenticatedUser = auth.User;
+
+            // If they're going to update the record of another user, they must be an administrator
+            if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator))
+                || (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess))
+            {
+                return false;
+            }
+
+            return true;
+        }
+
+        internal static SessionInfo GetSession(ISessionManager sessionManager, IAuthorizationContext authContext, HttpRequest request)
+        {
+            var authorization = authContext.GetAuthorizationInfo(request);
+            var user = authorization.User;
+            var session = sessionManager.LogSessionActivity(
+                authorization.Client,
+                authorization.Version,
+                authorization.DeviceId,
+                authorization.Device,
+                request.HttpContext.Connection.RemoteIpAddress.ToString(),
+                user);
+
+            if (session == null)
+            {
+                throw new ArgumentException("Session not found.");
+            }
+
+            return session;
+        }
+
+        /// <summary>
+        /// Get Guid array from string.
+        /// </summary>
+        /// <param name="value">String value.</param>
+        /// <returns>Guid array.</returns>
+        internal static Guid[] GetGuids(string? value)
+        {
+            if (value == null)
+            {
+                return Array.Empty<Guid>();
+            }
+
+            return Split(value, ',', true)
+                .Select(i => new Guid(i))
+                .ToArray();
+        }
+
+        /// <summary>
+        /// Gets the item fields.
+        /// </summary>
+        /// <param name="fields">The fields string.</param>
+        /// <returns>IEnumerable{ItemFields}.</returns>
+        internal static ItemFields[] GetItemFields(string? fields)
+        {
+            if (string.IsNullOrEmpty(fields))
+            {
+                return Array.Empty<ItemFields>();
+            }
+
+            return Split(fields, ',', true)
+                .Select(v =>
+                {
+                    if (Enum.TryParse(v, true, out ItemFields value))
+                    {
+                        return (ItemFields?)value;
+                    }
+
+                    return null;
+                }).Where(i => i.HasValue)
+                .Select(i => i!.Value)
+                .ToArray();
+        }
+
+        internal static IPAddress NormalizeIp(IPAddress ip)
+        {
+            return ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4() : ip;
+        }
+    }
+}

+ 29 - 73
MediaBrowser.Api/SimilarItemsHelper.cs → Jellyfin.Api/Helpers/SimilarItemsHelper.cs

@@ -1,95 +1,48 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Linq;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
 
-namespace MediaBrowser.Api
+namespace Jellyfin.Api.Helpers
 {
     /// <summary>
-    /// Class BaseGetSimilarItemsFromItem.
-    /// </summary>
-    public class BaseGetSimilarItemsFromItem : BaseGetSimilarItems
-    {
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-
-        public string ExcludeArtistIds { get; set; }
-    }
-
-    public class BaseGetSimilarItems : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions
-    {
-        [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableImages { get; set; }
-
-        [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableUserData { get; set; }
-
-        [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? ImageTypeLimit { get; set; }
-
-        [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string EnableImageTypes { get; set; }
-
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// The maximum number of items to return.
-        /// </summary>
-        /// <value>The limit.</value>
-        [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? Limit { get; set; }
-
-        /// <summary>
-        /// Fields to return within the items, in addition to basic information.
-        /// </summary>
-        /// <value>The fields.</value>
-        [ApiMember(Name = "Fields", Description = "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
-        public string Fields { get; set; }
-    }
-
-    /// <summary>
-    /// Class SimilarItemsHelper.
+    /// The similar items helper class.
     /// </summary>
     public static class SimilarItemsHelper
     {
-        internal static QueryResult<BaseItemDto> GetSimilarItemsResult(DtoOptions dtoOptions, IUserManager userManager, IItemRepository itemRepository, ILibraryManager libraryManager, IUserDataManager userDataRepository, IDtoService dtoService, BaseGetSimilarItemsFromItem request, Type[] includeTypes, Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore)
+        internal static QueryResult<BaseItemDto> GetSimilarItemsResult(
+            DtoOptions dtoOptions,
+            IUserManager userManager,
+            ILibraryManager libraryManager,
+            IDtoService dtoService,
+            Guid? userId,
+            string id,
+            string? excludeArtistIds,
+            int? limit,
+            Type[] includeTypes,
+            Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore)
         {
-            var user = !request.UserId.Equals(Guid.Empty) ? userManager.GetUserById(request.UserId) : null;
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? userManager.GetUserById(userId.Value)
+                : null;
 
-            var item = string.IsNullOrEmpty(request.Id) ?
-                (!request.UserId.Equals(Guid.Empty) ? libraryManager.GetUserRootFolder() :
-                libraryManager.RootFolder) : libraryManager.GetItemById(request.Id);
+            var item = string.IsNullOrEmpty(id) ?
+                (!userId.Equals(Guid.Empty) ? libraryManager.GetUserRootFolder() :
+                libraryManager.RootFolder) : libraryManager.GetItemById(id);
 
             var query = new InternalItemsQuery(user)
             {
                 IncludeItemTypes = includeTypes.Select(i => i.Name).ToArray(),
                 Recursive = true,
-                DtoOptions = dtoOptions
+                DtoOptions = dtoOptions,
+                ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds)
             };
 
-            // ExcludeArtistIds
-            if (!string.IsNullOrEmpty(request.ExcludeArtistIds))
-            {
-                query.ExcludeArtistIds = BaseApiService.GetGuids(request.ExcludeArtistIds);
-            }
-
             var inputItems = libraryManager.GetItemList(query);
 
             var items = GetSimilaritems(item, libraryManager, inputItems, getSimilarityScore)
@@ -97,9 +50,9 @@ namespace MediaBrowser.Api
 
             var returnItems = items;
 
-            if (request.Limit.HasValue)
+            if (limit.HasValue)
             {
-                returnItems = returnItems.Take(request.Limit.Value).ToList();
+                returnItems = returnItems.Take(limit.Value).ToList();
             }
 
             var dtos = dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
@@ -107,7 +60,6 @@ namespace MediaBrowser.Api
             return new QueryResult<BaseItemDto>
             {
                 Items = dtos,
-
                 TotalRecordCount = items.Count
             };
         }
@@ -120,7 +72,11 @@ namespace MediaBrowser.Api
         /// <param name="inputItems">The input items.</param>
         /// <param name="getSimilarityScore">The get similarity score.</param>
         /// <returns>IEnumerable{BaseItem}.</returns>
-        internal static IEnumerable<BaseItem> GetSimilaritems(BaseItem item, ILibraryManager libraryManager, IEnumerable<BaseItem> inputItems, Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore)
+        private static IEnumerable<BaseItem> GetSimilaritems(
+            BaseItem item,
+            ILibraryManager libraryManager,
+            IEnumerable<BaseItem> inputItems,
+            Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore)
         {
             var itemId = item.Id;
             inputItems = inputItems.Where(i => i.Id != itemId);

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