瀏覽代碼

Merge remote-tracking branch 'upstream/master' into quickconnect

Matt Montgomery 4 年之前
父節點
當前提交
4fa3d3f4f3
共有 100 個文件被更改,包括 12849 次插入1911 次删除
  1. 16 12
      .ci/azure-pipelines-abi.yml
  2. 16 13
      .ci/azure-pipelines-package.yml
  3. 12 2
      .vscode/launch.json
  4. 0 386
      Emby.Dlna/Api/DlnaServerService.cs
  5. 0 88
      Emby.Dlna/Api/DlnaService.cs
  6. 1 0
      Emby.Dlna/ContentDirectory/ControlHandler.cs
  7. 9 9
      Emby.Dlna/DlnaManager.cs
  8. 3 3
      Emby.Dlna/Main/DlnaEntryPoint.cs
  9. 0 191
      Emby.Notifications/Api/NotificationsService.cs
  10. 3 12
      Emby.Server.Implementations/ApplicationHost.cs
  11. 3 1
      Emby.Server.Implementations/Browser/BrowserLauncher.cs
  12. 10 14
      Emby.Server.Implementations/Channels/ChannelManager.cs
  13. 0 225
      Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs
  14. 76 159
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  15. 12 11
      Emby.Server.Implementations/Devices/DeviceManager.cs
  16. 5 7
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  17. 41 52
      Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
  18. 3 3
      Emby.Server.Implementations/HttpServer/HttpListenerHost.cs
  19. 14 20
      Emby.Server.Implementations/HttpServer/Security/AuthService.cs
  20. 7 6
      Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
  21. 4 4
      Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
  22. 2 1
      Emby.Server.Implementations/IO/FileRefresher.cs
  23. 1 1
      Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
  24. 1 1
      Emby.Server.Implementations/Images/FolderImageProvider.cs
  25. 1 0
      Emby.Server.Implementations/Images/GenreImageProvider.cs
  26. 20 3
      Emby.Server.Implementations/Library/IgnorePatterns.cs
  27. 9 10
      Emby.Server.Implementations/Library/LibraryManager.cs
  28. 15 18
      Emby.Server.Implementations/Library/MediaSourceManager.cs
  29. 1 1
      Emby.Server.Implementations/Library/MusicManager.cs
  30. 1 1
      Emby.Server.Implementations/Library/SearchEngine.cs
  31. 1 0
      Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
  32. 6 5
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
  33. 2 2
      Emby.Server.Implementations/Localization/Core/bn.json
  34. 4 4
      Emby.Server.Implementations/Localization/Core/de.json
  35. 28 28
      Emby.Server.Implementations/Localization/Core/he.json
  36. 2 2
      Emby.Server.Implementations/Localization/Core/it.json
  37. 94 13
      Emby.Server.Implementations/Localization/Core/uk.json
  38. 1 1
      Emby.Server.Implementations/Localization/Core/zh-TW.json
  39. 23 20
      Emby.Server.Implementations/Net/UdpSocket.cs
  40. 1 1
      Emby.Server.Implementations/Networking/NetworkManager.cs
  41. 20 44
      Emby.Server.Implementations/Playlists/PlaylistManager.cs
  42. 14 4
      Emby.Server.Implementations/Services/ServiceController.cs
  43. 4 4
      Emby.Server.Implementations/Services/ServiceHandler.cs
  44. 0 287
      Emby.Server.Implementations/Services/SwaggerService.cs
  45. 2 2
      Emby.Server.Implementations/Session/SessionManager.cs
  46. 16 14
      Emby.Server.Implementations/Session/SessionWebSocketListener.cs
  47. 2 2
      Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs
  48. 32 28
      Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
  49. 35 0
      Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs
  50. 35 0
      Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs
  51. 103 0
      Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
  52. 13 2
      Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
  53. 42 0
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
  54. 11 0
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs
  55. 44 0
      Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs
  56. 11 0
      Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs
  57. 56 0
      Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs
  58. 11 0
      Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs
  59. 56 0
      Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs
  60. 11 0
      Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs
  61. 18 4
      Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs
  62. 42 0
      Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs
  63. 11 0
      Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs
  64. 45 0
      Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs
  65. 11 0
      Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs
  66. 44 0
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
  67. 11 0
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs
  68. 24 2
      Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs
  69. 38 0
      Jellyfin.Api/Constants/InternalClaimTypes.cs
  70. 36 1
      Jellyfin.Api/Constants/Policies.cs
  71. 57 0
      Jellyfin.Api/Controllers/ActivityLogController.cs
  72. 134 0
      Jellyfin.Api/Controllers/AlbumsController.cs
  73. 97 0
      Jellyfin.Api/Controllers/ApiKeyController.cs
  74. 488 0
      Jellyfin.Api/Controllers/ArtistsController.cs
  75. 353 0
      Jellyfin.Api/Controllers/AudioController.cs
  76. 57 0
      Jellyfin.Api/Controllers/BrandingController.cs
  77. 256 0
      Jellyfin.Api/Controllers/ChannelsController.cs
  78. 111 0
      Jellyfin.Api/Controllers/CollectionController.cs
  79. 126 0
      Jellyfin.Api/Controllers/ConfigurationController.cs
  80. 273 0
      Jellyfin.Api/Controllers/DashboardController.cs
  81. 155 0
      Jellyfin.Api/Controllers/DevicesController.cs
  82. 176 0
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  83. 132 0
      Jellyfin.Api/Controllers/DlnaController.cs
  84. 257 0
      Jellyfin.Api/Controllers/DlnaServerController.cs
  85. 2221 0
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  86. 191 0
      Jellyfin.Api/Controllers/EnvironmentController.cs
  87. 218 0
      Jellyfin.Api/Controllers/FilterController.cs
  88. 320 0
      Jellyfin.Api/Controllers/GenresController.cs
  89. 154 0
      Jellyfin.Api/Controllers/HlsSegmentController.cs
  90. 231 0
      Jellyfin.Api/Controllers/ImageByNameController.cs
  91. 1304 0
      Jellyfin.Api/Controllers/ImageController.cs
  92. 330 0
      Jellyfin.Api/Controllers/InstantMixController.cs
  93. 364 0
      Jellyfin.Api/Controllers/ItemLookupController.cs
  94. 85 0
      Jellyfin.Api/Controllers/ItemRefreshController.cs
  95. 239 187
      Jellyfin.Api/Controllers/ItemUpdateController.cs
  96. 593 0
      Jellyfin.Api/Controllers/ItemsController.cs
  97. 1036 0
      Jellyfin.Api/Controllers/LibraryController.cs
  98. 331 0
      Jellyfin.Api/Controllers/LibraryStructureController.cs
  99. 1238 0
      Jellyfin.Api/Controllers/LiveTvController.cs
  100. 76 0
      Jellyfin.Api/Controllers/LocalizationController.cs

+ 16 - 12
.ci/azure-pipelines-abi.yml

@@ -12,10 +12,12 @@ parameters:
 jobs:
   - job: CompatibilityCheck
     displayName: Compatibility Check
+    dependsOn: Build
+    condition: and(succeeded(), variables['System.PullRequest.PullRequestNumber'])
+
     pool:
       vmImage: "${{ parameters.LinuxImage }}"
-    # only execute for pull requests
-    condition: and(succeeded(), variables['System.PullRequest.PullRequestNumber'])
+
     strategy:
       matrix:
         ${{ each Package in parameters.Packages }}:
@@ -23,7 +25,7 @@ jobs:
             NugetPackageName: ${{ Package.value.NugetPackageName }}
             AssemblyFileName: ${{ Package.value.AssemblyFileName }}
       maxParallel: 2
-    dependsOn: Build
+
     steps:
       - checkout: none
 
@@ -34,32 +36,33 @@ jobs:
           version: ${{ parameters.DotNetSdkVersion }}
 
       - task: DotNetCoreCLI@2
-        displayName: 'Install ABI CompatibilityChecker tool'
+        displayName: 'Install ABI CompatibilityChecker Tool'
         inputs:
           command: custom
           custom: tool
           arguments: 'update compatibilitychecker -g'
 
       - task: DownloadPipelineArtifact@2
-        displayName: "Download New Assembly Build Artifact"
+        displayName: 'Download New Assembly Build Artifact'
         inputs:
-          source: "current"
+          source: 'current'
           artifact: "$(NugetPackageName)"
           path: "$(System.ArtifactsDirectory)/new-artifacts"
           runVersion: "latest"
 
       - task: CopyFiles@2
-        displayName: "Copy New Assembly Build Artifact"
+        displayName: 'Copy New Assembly Build Artifact'
         inputs:
           sourceFolder: $(System.ArtifactsDirectory)/new-artifacts
-          contents: "**/*.dll"
+          contents: '**/*.dll'
           targetFolder: $(System.ArtifactsDirectory)/new-release
           cleanTargetFolder: true
           overWrite: true
           flattenFolders: true
 
       - task: DownloadPipelineArtifact@2
-        displayName: "Download Reference Assembly Build Artifact"
+        displayName: 'Download Reference Assembly Build Artifact'
+        enabled: false
         inputs:
           source: "specific"
           artifact: "$(NugetPackageName)"
@@ -70,18 +73,19 @@ jobs:
           runBranch: "refs/heads/$(System.PullRequest.TargetBranch)"
 
       - task: CopyFiles@2
-        displayName: "Copy Reference Assembly Build Artifact"
+        displayName: 'Copy Reference Assembly Build Artifact'
+        enabled: false
         inputs:
           sourceFolder: $(System.ArtifactsDirectory)/current-artifacts
-          contents: "**/*.dll"
+          contents: '**/*.dll'
           targetFolder: $(System.ArtifactsDirectory)/current-release
           cleanTargetFolder: true
           overWrite: true
           flattenFolders: true
 
-      # The `--warnings-only` switch will swallow the return code and not emit any errors.
       - task: DotNetCoreCLI@2
         displayName: 'Execute ABI Compatibility Check Tool'
+        enabled: false
         inputs:
           command: custom
           custom: compat

+ 16 - 13
.ci/azure-pipelines-package.yml

@@ -80,7 +80,15 @@ jobs:
   pool:
     vmImage: 'ubuntu-latest'
 
+  variables:
+  - name: JellyfinVersion
+    value: 0.0.0
+
   steps:
+  - script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )"
+    displayName: Set release version (stable)
+    condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
+
   - task: Docker@2
     displayName: 'Push Unstable Image'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
@@ -105,9 +113,10 @@ jobs:
       containerRegistry: Docker Hub
       tags: |
         stable-$(Build.BuildNumber)-$(BuildConfiguration)
-        stable-$(BuildConfiguration)
+        $(JellyfinVersion)-$(BuildConfiguration)
 
 - job: CollectArtifacts
+  timeoutInMinutes: 10
   displayName: 'Collect Artifacts'
   dependsOn:
   - BuildPackage
@@ -123,29 +132,23 @@ jobs:
     condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
     inputs:
       sshEndpoint: repository
-      runOptions: 'inline'
-      inline: |
-        sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable
-        rm $0
-        exit
+      runOptions: 'commands'
+      commands: sudo -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable
 
   - task: SSH@0
     displayName: 'Update Stable Repository'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
     inputs:
       sshEndpoint: repository
-      runOptions: 'inline'
-      inline: |
-        sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber)
-        rm $0
-        exit
-
+      runOptions: 'commands'
+      commands: sudo -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber)
+      
 - job: PublishNuget
   displayName: 'Publish NuGet packages'
   dependsOn:
   - BuildPackage
   condition: and(succeeded('BuildPackage'), startsWith(variables['Build.SourceBranch'], 'refs/tags'))
-  
+
   pool:
     vmImage: 'ubuntu-latest'
 

+ 12 - 2
.vscode/launch.json

@@ -6,11 +6,21 @@
             "type": "coreclr",
             "request": "launch",
             "preLaunchTask": "build",
-            // If you have changed target frameworks, make sure to update the program path.
             "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll",
             "args": [],
             "cwd": "${workspaceFolder}/Jellyfin.Server",
-            // For more information about the 'console' field, see https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#console-terminal-window
+            "console": "internalConsole",
+            "stopAtEntry": false,
+            "internalConsoleOptions": "openOnSessionStart"
+        },
+        {
+            "name": ".NET Core Launch (nowebclient)",
+            "type": "coreclr",
+            "request": "launch",
+            "preLaunchTask": "build",
+            "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll",
+            "args": ["--nowebclient"],
+            "cwd": "${workspaceFolder}/Jellyfin.Server",
             "console": "internalConsole",
             "stopAtEntry": false,
             "internalConsoleOptions": "openOnSessionStart"

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

+ 1 - 0
Emby.Dlna/ContentDirectory/ControlHandler.cs

@@ -11,6 +11,7 @@ using System.Xml;
 using Emby.Dlna.Didl;
 using Emby.Dlna.Service;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Drawing;

+ 9 - 9
Emby.Dlna/DlnaManager.cs

@@ -122,15 +122,15 @@ namespace Emby.Dlna
             var builder = new StringBuilder();
 
             builder.AppendLine("No matching device profile found. The default will need to be used.");
-            builder.AppendLine(string.Format("DeviceDescription:{0}", profile.DeviceDescription ?? string.Empty));
-            builder.AppendLine(string.Format("FriendlyName:{0}", profile.FriendlyName ?? string.Empty));
-            builder.AppendLine(string.Format("Manufacturer:{0}", profile.Manufacturer ?? string.Empty));
-            builder.AppendLine(string.Format("ManufacturerUrl:{0}", profile.ManufacturerUrl ?? string.Empty));
-            builder.AppendLine(string.Format("ModelDescription:{0}", profile.ModelDescription ?? string.Empty));
-            builder.AppendLine(string.Format("ModelName:{0}", profile.ModelName ?? string.Empty));
-            builder.AppendLine(string.Format("ModelNumber:{0}", profile.ModelNumber ?? string.Empty));
-            builder.AppendLine(string.Format("ModelUrl:{0}", profile.ModelUrl ?? string.Empty));
-            builder.AppendLine(string.Format("SerialNumber:{0}", profile.SerialNumber ?? string.Empty));
+            builder.AppendFormat(CultureInfo.InvariantCulture, "DeviceDescription:{0}", profile.DeviceDescription ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "FriendlyName:{0}", profile.FriendlyName ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "Manufacturer:{0}", profile.Manufacturer ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "ManufacturerUrl:{0}", profile.ManufacturerUrl ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "ModelDescription:{0}", profile.ModelDescription ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "ModelName:{0}", profile.ModelName ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "ModelNumber:{0}", profile.ModelNumber ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "ModelUrl:{0}", profile.ModelUrl ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "SerialNumber:{0}", profile.SerialNumber ?? string.Empty).AppendLine();
 
             _logger.LogInformation(builder.ToString());
         }

+ 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 - 12
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;
@@ -98,7 +97,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;
@@ -556,8 +554,6 @@ namespace Emby.Server.Implementations
             serviceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>();
             serviceCollection.AddSingleton<IUserDataManager, UserDataManager>();
 
-            serviceCollection.AddSingleton<IDisplayPreferencesRepository, SqliteDisplayPreferencesRepository>();
-
             serviceCollection.AddSingleton<IItemRepository, SqliteItemRepository>();
 
             serviceCollection.AddSingleton<IAuthenticationRepository, AuthenticationRepository>();
@@ -637,6 +633,8 @@ namespace Emby.Server.Implementations
             serviceCollection.AddSingleton<EncodingHelper>();
 
             serviceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>();
+
+            serviceCollection.AddSingleton<TranscodingJobHelper>();
         }
 
         /// <summary>
@@ -653,7 +651,6 @@ namespace Emby.Server.Implementations
             _httpServer = Resolve<IHttpServer>();
             _httpClient = Resolve<IHttpClient>();
 
-            ((SqliteDisplayPreferencesRepository)Resolve<IDisplayPreferencesRepository>()).Initialize();
             ((AuthenticationRepository)Resolve<IAuthenticationRepository>()).Initialize();
 
             SetStaticProperties();
@@ -1037,12 +1034,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>

+ 10 - 14
Emby.Server.Implementations/Channels/ChannelManager.cs

@@ -1,5 +1,4 @@
 using System;
-using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
@@ -7,6 +6,7 @@ using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Progress;
 using MediaBrowser.Controller.Channels;
@@ -22,6 +22,7 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Serialization;
+using Microsoft.Extensions.Caching.Memory;
 using Microsoft.Extensions.Logging;
 using Episode = MediaBrowser.Controller.Entities.TV.Episode;
 using Movie = MediaBrowser.Controller.Entities.Movies.Movie;
@@ -45,10 +46,7 @@ namespace Emby.Server.Implementations.Channels
         private readonly IFileSystem _fileSystem;
         private readonly IJsonSerializer _jsonSerializer;
         private readonly IProviderManager _providerManager;
-
-        private readonly ConcurrentDictionary<string, Tuple<DateTime, List<MediaSourceInfo>>> _channelItemMediaInfo =
-            new ConcurrentDictionary<string, Tuple<DateTime, List<MediaSourceInfo>>>();
-
+        private readonly IMemoryCache _memoryCache;
         private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1);
 
         /// <summary>
@@ -63,6 +61,7 @@ namespace Emby.Server.Implementations.Channels
         /// <param name="userDataManager">The user data manager.</param>
         /// <param name="jsonSerializer">The JSON serializer.</param>
         /// <param name="providerManager">The provider manager.</param>
+        /// <param name="memoryCache">The memory cache.</param>
         public ChannelManager(
             IUserManager userManager,
             IDtoService dtoService,
@@ -72,7 +71,8 @@ namespace Emby.Server.Implementations.Channels
             IFileSystem fileSystem,
             IUserDataManager userDataManager,
             IJsonSerializer jsonSerializer,
-            IProviderManager providerManager)
+            IProviderManager providerManager,
+            IMemoryCache memoryCache)
         {
             _userManager = userManager;
             _dtoService = dtoService;
@@ -83,6 +83,7 @@ namespace Emby.Server.Implementations.Channels
             _userDataManager = userDataManager;
             _jsonSerializer = jsonSerializer;
             _providerManager = providerManager;
+            _memoryCache = memoryCache;
         }
 
         internal IChannel[] Channels { get; private set; }
@@ -417,20 +418,15 @@ namespace Emby.Server.Implementations.Channels
 
         private async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaSourcesInternal(IRequiresMediaInfoCallback channel, string id, CancellationToken cancellationToken)
         {
-            if (_channelItemMediaInfo.TryGetValue(id, out Tuple<DateTime, List<MediaSourceInfo>> cachedInfo))
+            if (_memoryCache.TryGetValue(id, out List<MediaSourceInfo> cachedInfo))
             {
-                if ((DateTime.UtcNow - cachedInfo.Item1).TotalMinutes < 5)
-                {
-                    return cachedInfo.Item2;
-                }
+                return cachedInfo;
             }
 
             var mediaInfo = await channel.GetChannelItemMediaInfo(id, cancellationToken)
                    .ConfigureAwait(false);
             var list = mediaInfo.ToList();
-
-            var item2 = new Tuple<DateTime, List<MediaSourceInfo>>(DateTime.UtcNow, list);
-            _channelItemMediaInfo.AddOrUpdate(id, item2, (key, oldValue) => item2);
+            _memoryCache.Set(id, list, DateTimeOffset.UtcNow.AddMinutes(5));
 
             return list;
         }

+ 0 - 225
Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs

@@ -1,225 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Text.Json;
-using System.Threading;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Json;
-using MediaBrowser.Controller.Persistence;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.IO;
-using Microsoft.Extensions.Logging;
-using SQLitePCL.pretty;
-
-namespace Emby.Server.Implementations.Data
-{
-    /// <summary>
-    /// Class SQLiteDisplayPreferencesRepository.
-    /// </summary>
-    public class SqliteDisplayPreferencesRepository : BaseSqliteRepository, IDisplayPreferencesRepository
-    {
-        private readonly IFileSystem _fileSystem;
-
-        private readonly JsonSerializerOptions _jsonOptions;
-
-        public SqliteDisplayPreferencesRepository(ILogger<SqliteDisplayPreferencesRepository> logger, IApplicationPaths appPaths, IFileSystem fileSystem)
-            : base(logger)
-        {
-            _fileSystem = fileSystem;
-
-            _jsonOptions = JsonDefaults.GetOptions();
-
-            DbFilePath = Path.Combine(appPaths.DataPath, "displaypreferences.db");
-        }
-
-        /// <summary>
-        /// Gets the name of the repository.
-        /// </summary>
-        /// <value>The name.</value>
-        public string Name => "SQLite";
-
-        public void Initialize()
-        {
-            try
-            {
-                InitializeInternal();
-            }
-            catch (Exception ex)
-            {
-                Logger.LogError(ex, "Error loading database file. Will reset and retry.");
-
-                _fileSystem.DeleteFile(DbFilePath);
-
-                InitializeInternal();
-            }
-        }
-
-        /// <summary>
-        /// Opens the connection to the database.
-        /// </summary>
-        /// <returns>Task.</returns>
-        private void InitializeInternal()
-        {
-            string[] queries =
-            {
-                "create table if not exists userdisplaypreferences (id GUID NOT NULL, userId GUID NOT NULL, client text NOT NULL, data BLOB NOT NULL)",
-                "create unique index if not exists userdisplaypreferencesindex on userdisplaypreferences (id, userId, client)"
-            };
-
-            using (var connection = GetConnection())
-            {
-                connection.RunQueries(queries);
-            }
-        }
-
-        /// <summary>
-        /// Save the display preferences associated with an item in the repo.
-        /// </summary>
-        /// <param name="displayPreferences">The display preferences.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="client">The client.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        /// <exception cref="ArgumentNullException">item</exception>
-        public void SaveDisplayPreferences(DisplayPreferences displayPreferences, Guid userId, string client, CancellationToken cancellationToken)
-        {
-            if (displayPreferences == null)
-            {
-                throw new ArgumentNullException(nameof(displayPreferences));
-            }
-
-            if (string.IsNullOrEmpty(displayPreferences.Id))
-            {
-                throw new ArgumentException("Display preferences has an invalid Id", nameof(displayPreferences));
-            }
-
-            cancellationToken.ThrowIfCancellationRequested();
-
-            using (var connection = GetConnection())
-            {
-                connection.RunInTransaction(
-                    db => SaveDisplayPreferences(displayPreferences, userId, client, db),
-                    TransactionMode);
-            }
-        }
-
-        private void SaveDisplayPreferences(DisplayPreferences displayPreferences, Guid userId, string client, IDatabaseConnection connection)
-        {
-            var serialized = JsonSerializer.SerializeToUtf8Bytes(displayPreferences, _jsonOptions);
-
-            using (var statement = connection.PrepareStatement("replace into userdisplaypreferences (id, userid, client, data) values (@id, @userId, @client, @data)"))
-            {
-                statement.TryBind("@id", new Guid(displayPreferences.Id).ToByteArray());
-                statement.TryBind("@userId", userId.ToByteArray());
-                statement.TryBind("@client", client);
-                statement.TryBind("@data", serialized);
-
-                statement.MoveNext();
-            }
-        }
-
-        /// <summary>
-        /// Save all display preferences associated with a user in the repo.
-        /// </summary>
-        /// <param name="displayPreferences">The display preferences.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        /// <exception cref="ArgumentNullException">item</exception>
-        public void SaveAllDisplayPreferences(IEnumerable<DisplayPreferences> displayPreferences, Guid userId, CancellationToken cancellationToken)
-        {
-            if (displayPreferences == null)
-            {
-                throw new ArgumentNullException(nameof(displayPreferences));
-            }
-
-            cancellationToken.ThrowIfCancellationRequested();
-
-            using (var connection = GetConnection())
-            {
-                connection.RunInTransaction(
-                    db =>
-                    {
-                        foreach (var displayPreference in displayPreferences)
-                        {
-                            SaveDisplayPreferences(displayPreference, userId, displayPreference.Client, db);
-                        }
-                    },
-                    TransactionMode);
-            }
-        }
-
-        /// <summary>
-        /// Gets the display preferences.
-        /// </summary>
-        /// <param name="displayPreferencesId">The display preferences id.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="client">The client.</param>
-        /// <returns>Task{DisplayPreferences}.</returns>
-        /// <exception cref="ArgumentNullException">item</exception>
-        public DisplayPreferences GetDisplayPreferences(string displayPreferencesId, Guid userId, string client)
-        {
-            if (string.IsNullOrEmpty(displayPreferencesId))
-            {
-                throw new ArgumentNullException(nameof(displayPreferencesId));
-            }
-
-            var guidId = displayPreferencesId.GetMD5();
-
-            using (var connection = GetConnection(true))
-            {
-                using (var statement = connection.PrepareStatement("select data from userdisplaypreferences where id = @id and userId=@userId and client=@client"))
-                {
-                    statement.TryBind("@id", guidId.ToByteArray());
-                    statement.TryBind("@userId", userId.ToByteArray());
-                    statement.TryBind("@client", client);
-
-                    foreach (var row in statement.ExecuteQuery())
-                    {
-                        return Get(row);
-                    }
-                }
-            }
-
-            return new DisplayPreferences
-            {
-                Id = guidId.ToString("N", CultureInfo.InvariantCulture)
-            };
-        }
-
-        /// <summary>
-        /// Gets all display preferences for the given user.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <returns>Task{DisplayPreferences}.</returns>
-        /// <exception cref="ArgumentNullException">item</exception>
-        public IEnumerable<DisplayPreferences> GetAllDisplayPreferences(Guid userId)
-        {
-            var list = new List<DisplayPreferences>();
-
-            using (var connection = GetConnection(true))
-            using (var statement = connection.PrepareStatement("select data from userdisplaypreferences where userId=@userId"))
-            {
-                statement.TryBind("@userId", userId.ToByteArray());
-
-                foreach (var row in statement.ExecuteQuery())
-                {
-                    list.Add(Get(row));
-                }
-            }
-
-            return list;
-        }
-
-        private DisplayPreferences Get(IReadOnlyList<IResultSetValue> row)
-            => JsonSerializer.Deserialize<DisplayPreferences>(row[0].ToBlob(), _jsonOptions);
-
-        public void SaveDisplayPreferences(DisplayPreferences displayPreferences, string userId, string client, CancellationToken cancellationToken)
-            => SaveDisplayPreferences(displayPreferences, new Guid(userId), client, cancellationToken);
-
-        public DisplayPreferences GetDisplayPreferences(string displayPreferencesId, string userId, string client)
-            => GetDisplayPreferences(displayPreferencesId, new Guid(userId), client);
-    }
-}

+ 76 - 159
Emby.Server.Implementations/Data/SqliteItemRepository.cs

@@ -9,6 +9,7 @@ using System.Text;
 using System.Text.Json;
 using System.Threading;
 using Emby.Server.Implementations.Playlists;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Json;
 using MediaBrowser.Controller;
@@ -400,6 +401,8 @@ namespace Emby.Server.Implementations.Data
             "OwnerId"
         };
 
+        private static readonly string _retriveItemColumnsSelectQuery = $"select {string.Join(',', _retriveItemColumns)} from TypedBaseItems where guid = @guid";
+
         private static readonly string[] _mediaStreamSaveColumns =
         {
             "ItemId",
@@ -439,6 +442,12 @@ namespace Emby.Server.Implementations.Data
             "ColorTransfer"
         };
 
+        private static readonly string _mediaStreamSaveColumnsInsertQuery =
+            $"insert into mediastreams ({string.Join(',', _mediaStreamSaveColumns)}) values ";
+
+        private static readonly string _mediaStreamSaveColumnsSelectQuery =
+            $"select {string.Join(',', _mediaStreamSaveColumns)} from mediastreams where ItemId=@ItemId";
+
         private static readonly string[] _mediaAttachmentSaveColumns =
         {
             "ItemId",
@@ -450,102 +459,15 @@ namespace Emby.Server.Implementations.Data
             "MIMEType"
         };
 
-        private static readonly string _mediaAttachmentInsertPrefix;
-
-        private static string GetSaveItemCommandText()
-        {
-            var saveColumns = new[]
-            {
-                "guid",
-                "type",
-                "data",
-                "Path",
-                "StartDate",
-                "EndDate",
-                "ChannelId",
-                "IsMovie",
-                "IsSeries",
-                "EpisodeTitle",
-                "IsRepeat",
-                "CommunityRating",
-                "CustomRating",
-                "IndexNumber",
-                "IsLocked",
-                "Name",
-                "OfficialRating",
-                "MediaType",
-                "Overview",
-                "ParentIndexNumber",
-                "PremiereDate",
-                "ProductionYear",
-                "ParentId",
-                "Genres",
-                "InheritedParentalRatingValue",
-                "SortName",
-                "ForcedSortName",
-                "RunTimeTicks",
-                "Size",
-                "DateCreated",
-                "DateModified",
-                "PreferredMetadataLanguage",
-                "PreferredMetadataCountryCode",
-                "Width",
-                "Height",
-                "DateLastRefreshed",
-                "DateLastSaved",
-                "IsInMixedFolder",
-                "LockedFields",
-                "Studios",
-                "Audio",
-                "ExternalServiceId",
-                "Tags",
-                "IsFolder",
-                "UnratedType",
-                "TopParentId",
-                "TrailerTypes",
-                "CriticRating",
-                "CleanName",
-                "PresentationUniqueKey",
-                "OriginalTitle",
-                "PrimaryVersionId",
-                "DateLastMediaAdded",
-                "Album",
-                "IsVirtualItem",
-                "SeriesName",
-                "UserDataKey",
-                "SeasonName",
-                "SeasonId",
-                "SeriesId",
-                "ExternalSeriesId",
-                "Tagline",
-                "ProviderIds",
-                "Images",
-                "ProductionLocations",
-                "ExtraIds",
-                "TotalBitrate",
-                "ExtraType",
-                "Artists",
-                "AlbumArtists",
-                "ExternalId",
-                "SeriesPresentationUniqueKey",
-                "ShowId",
-                "OwnerId"
-            };
-
-            var saveItemCommandCommandText = "replace into TypedBaseItems (" + string.Join(",", saveColumns) + ") values (";
+        private static readonly string _mediaAttachmentSaveColumnsSelectQuery =
+            $"select {string.Join(',', _mediaAttachmentSaveColumns)} from mediaattachments where ItemId=@ItemId";
 
-            for (var i = 0; i < saveColumns.Length; i++)
-            {
-                if (i != 0)
-                {
-                    saveItemCommandCommandText += ",";
-                }
-
-                saveItemCommandCommandText += "@" + saveColumns[i];
-            }
+        private static readonly string _mediaAttachmentInsertPrefix;
 
-            return saveItemCommandCommandText + ")";
-        }
+        private const string SaveItemCommandText =
+            @"replace into TypedBaseItems
+            (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId)
+            values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)";
 
         /// <summary>
         /// Save a standard item in the repo.
@@ -636,7 +558,7 @@ namespace Emby.Server.Implementations.Data
         {
             var statements = PrepareAll(db, new string[]
             {
-                GetSaveItemCommandText(),
+                SaveItemCommandText,
                 "delete from AncestorIds where ItemId=@ItemId"
             }).ToList();
 
@@ -1056,7 +978,10 @@ namespace Emby.Server.Implementations.Data
                     continue;
                 }
 
-                str.Append($"{i.Key}={i.Value}|");
+                str.Append(i.Key)
+                    .Append('=')
+                    .Append(i.Value)
+                    .Append('|');
             }
 
             if (str.Length == 0)
@@ -1110,8 +1035,8 @@ namespace Emby.Server.Implementations.Data
                     continue;
                 }
 
-                str.Append(ToValueString(i))
-                    .Append('|');
+                AppendItemImageInfo(str, i);
+                str.Append('|');
             }
 
             str.Length -= 1; // Remove last |
@@ -1145,26 +1070,26 @@ namespace Emby.Server.Implementations.Data
             item.ImageInfos = list.ToArray();
         }
 
-        public string ToValueString(ItemImageInfo image)
+        public void AppendItemImageInfo(StringBuilder bldr, ItemImageInfo image)
         {
-            const string Delimeter = "*";
+            const char Delimiter = '*';
 
             var path = image.Path ?? string.Empty;
             var hash = image.BlurHash ?? string.Empty;
 
-            return GetPathToSave(path) +
-                   Delimeter +
-                   image.DateModified.Ticks.ToString(CultureInfo.InvariantCulture) +
-                   Delimeter +
-                   image.Type +
-                   Delimeter +
-                   image.Width.ToString(CultureInfo.InvariantCulture) +
-                   Delimeter +
-                   image.Height.ToString(CultureInfo.InvariantCulture) +
-                   Delimeter +
-                   // Replace delimiters with other characters.
-                   // This can be removed when we migrate to a proper DB.
-                   hash.Replace('*', '/').Replace('|', '\\');
+            bldr.Append(GetPathToSave(path))
+                .Append(Delimiter)
+                .Append(image.DateModified.Ticks)
+                .Append(Delimiter)
+                .Append(image.Type)
+                .Append(Delimiter)
+                .Append(image.Width)
+                .Append(Delimiter)
+                .Append(image.Height)
+                .Append(Delimiter)
+                // Replace delimiters with other characters.
+                // This can be removed when we migrate to a proper DB.
+                .Append(hash.Replace('*', '/').Replace('|', '\\'));
         }
 
         public ItemImageInfo ItemImageInfoFromValueString(string value)
@@ -1226,7 +1151,7 @@ namespace Emby.Server.Implementations.Data
 
             using (var connection = GetConnection(true))
             {
-                using (var statement = PrepareStatement(connection, "select " + string.Join(",", _retriveItemColumns) + " from TypedBaseItems where guid = @guid"))
+                using (var statement = PrepareStatement(connection, _retriveItemColumnsSelectQuery))
                 {
                     statement.TryBind("@guid", id);
 
@@ -2776,82 +2701,82 @@ namespace Emby.Server.Implementations.Data
 
         private string FixUnicodeChars(string buffer)
         {
-            if (buffer.IndexOf('\u2013') > -1)
+            if (buffer.IndexOf('\u2013', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2013', '-'); // en dash
             }
 
-            if (buffer.IndexOf('\u2014') > -1)
+            if (buffer.IndexOf('\u2014', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2014', '-'); // em dash
             }
 
-            if (buffer.IndexOf('\u2015') > -1)
+            if (buffer.IndexOf('\u2015', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2015', '-'); // horizontal bar
             }
 
-            if (buffer.IndexOf('\u2017') > -1)
+            if (buffer.IndexOf('\u2017', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2017', '_'); // double low line
             }
 
-            if (buffer.IndexOf('\u2018') > -1)
+            if (buffer.IndexOf('\u2018', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2018', '\''); // left single quotation mark
             }
 
-            if (buffer.IndexOf('\u2019') > -1)
+            if (buffer.IndexOf('\u2019', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2019', '\''); // right single quotation mark
             }
 
-            if (buffer.IndexOf('\u201a') > -1)
+            if (buffer.IndexOf('\u201a', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u201a', ','); // single low-9 quotation mark
             }
 
-            if (buffer.IndexOf('\u201b') > -1)
+            if (buffer.IndexOf('\u201b', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u201b', '\''); // single high-reversed-9 quotation mark
             }
 
-            if (buffer.IndexOf('\u201c') > -1)
+            if (buffer.IndexOf('\u201c', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u201c', '\"'); // left double quotation mark
             }
 
-            if (buffer.IndexOf('\u201d') > -1)
+            if (buffer.IndexOf('\u201d', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u201d', '\"'); // right double quotation mark
             }
 
-            if (buffer.IndexOf('\u201e') > -1)
+            if (buffer.IndexOf('\u201e', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u201e', '\"'); // double low-9 quotation mark
             }
 
-            if (buffer.IndexOf('\u2026') > -1)
+            if (buffer.IndexOf('\u2026', StringComparison.Ordinal) > -1)
             {
-                buffer = buffer.Replace("\u2026", "..."); // horizontal ellipsis
+                buffer = buffer.Replace("\u2026", "...", StringComparison.Ordinal); // horizontal ellipsis
             }
 
-            if (buffer.IndexOf('\u2032') > -1)
+            if (buffer.IndexOf('\u2032', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2032', '\''); // prime
             }
 
-            if (buffer.IndexOf('\u2033') > -1)
+            if (buffer.IndexOf('\u2033', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2033', '\"'); // double prime
             }
 
-            if (buffer.IndexOf('\u0060') > -1)
+            if (buffer.IndexOf('\u0060', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u0060', '\''); // grave accent
             }
 
-            if (buffer.IndexOf('\u00B4') > -1)
+            if (buffer.IndexOf('\u00B4', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u00B4', '\''); // acute accent
             }
@@ -3000,7 +2925,6 @@ namespace Emby.Server.Implementations.Data
             {
                 connection.RunInTransaction(db =>
                 {
-
                     var statements = PrepareAll(db, statementTexts).ToList();
 
                     if (!isReturningZeroItems)
@@ -4670,8 +4594,12 @@ namespace Emby.Server.Implementations.Data
 
             if (query.BlockUnratedItems.Length > 1)
             {
-                var inClause = string.Join(",", query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'"));
-                whereClauses.Add(string.Format("(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))", inClause));
+                var inClause = string.Join(',', query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'"));
+                whereClauses.Add(
+                    string.Format(
+                        CultureInfo.InvariantCulture,
+                        "(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))",
+                        inClause));
             }
 
             if (query.ExcludeInheritedTags.Length > 0)
@@ -4680,7 +4608,7 @@ namespace Emby.Server.Implementations.Data
                 if (statement == null)
                 {
                     int index = 0;
-                    string excludedTags = string.Join(",", query.ExcludeInheritedTags.Select(t => paramName + index++));
+                    string excludedTags = string.Join(',', query.ExcludeInheritedTags.Select(t => paramName + index++));
                     whereClauses.Add("((select CleanValue from itemvalues where ItemId=Guid and Type=6 and cleanvalue in (" + excludedTags + ")) is null)");
                 }
                 else
@@ -5734,10 +5662,10 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             const int Limit = 100;
             var startIndex = 0;
 
+            const string StartInsertText = "insert into ItemValues (ItemId, Type, Value, CleanValue) values ";
+            var insertText = new StringBuilder(StartInsertText);
             while (startIndex < values.Count)
             {
-                var insertText = new StringBuilder("insert into ItemValues (ItemId, Type, Value, CleanValue) values ");
-
                 var endIndex = Math.Min(values.Count, startIndex + Limit);
 
                 for (var i = startIndex; i < endIndex; i++)
@@ -5779,6 +5707,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                 }
 
                 startIndex += Limit;
+                insertText.Length = StartInsertText.Length;
             }
         }
 
@@ -5816,10 +5745,10 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             var startIndex = 0;
             var listIndex = 0;
 
+            const string StartInsertText = "insert into People (ItemId, Name, Role, PersonType, SortOrder, ListOrder) values ";
+            var insertText = new StringBuilder(StartInsertText);
             while (startIndex < people.Count)
             {
-                var insertText = new StringBuilder("insert into People (ItemId, Name, Role, PersonType, SortOrder, ListOrder) values ");
-
                 var endIndex = Math.Min(people.Count, startIndex + Limit);
                 for (var i = startIndex; i < endIndex; i++)
                 {
@@ -5853,6 +5782,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                 }
 
                 startIndex += Limit;
+                insertText.Length = StartInsertText.Length;
             }
         }
 
@@ -5891,10 +5821,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                 throw new ArgumentNullException(nameof(query));
             }
 
-            var cmdText = "select "
-                        + string.Join(",", _mediaStreamSaveColumns)
-                        + " from mediastreams where"
-                        + " ItemId=@ItemId";
+            var cmdText = _mediaStreamSaveColumnsSelectQuery;
 
             if (query.Type.HasValue)
             {
@@ -5971,18 +5898,9 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             const int Limit = 10;
             var startIndex = 0;
 
+            var insertText = new StringBuilder(_mediaStreamSaveColumnsInsertQuery);
             while (startIndex < streams.Count)
             {
-                var insertText = new StringBuilder("insert into mediastreams (");
-                foreach (var column in _mediaStreamSaveColumns)
-                {
-                    insertText.Append(column).Append(',');
-                }
-
-                // Remove last comma
-                insertText.Length--;
-                insertText.Append(") values ");
-
                 var endIndex = Math.Min(streams.Count, startIndex + Limit);
 
                 for (var i = startIndex; i < endIndex; i++)
@@ -6065,6 +5983,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                 }
 
                 startIndex += Limit;
+                insertText.Length = _mediaStreamSaveColumnsInsertQuery.Length;
             }
         }
 
@@ -6248,10 +6167,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                 throw new ArgumentNullException(nameof(query));
             }
 
-            var cmdText = "select "
-                        + string.Join(",", _mediaAttachmentSaveColumns)
-                        + " from mediaattachments where"
-                        + " ItemId=@ItemId";
+            var cmdText = _mediaAttachmentSaveColumnsSelectQuery;
 
             if (query.Index.HasValue)
             {
@@ -6319,10 +6235,9 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
         {
             const int InsertAtOnce = 10;
 
+            var insertText = new StringBuilder(_mediaAttachmentInsertPrefix);
             for (var startIndex = 0; startIndex < attachments.Count; startIndex += InsertAtOnce)
             {
-                var insertText = new StringBuilder(_mediaAttachmentInsertPrefix);
-
                 var endIndex = Math.Min(attachments.Count, startIndex + InsertAtOnce);
 
                 for (var i = startIndex; i < endIndex; i++)
@@ -6368,6 +6283,8 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                     statement.Reset();
                     statement.MoveNext();
                 }
+
+                insertText.Length = _mediaAttachmentInsertPrefix.Length;
             }
         }
 

+ 12 - 11
Emby.Server.Implementations/Devices/DeviceManager.cs

@@ -5,8 +5,8 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
 using System.Linq;
-using Jellyfin.Data.Enums;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Devices;
@@ -17,16 +17,17 @@ using MediaBrowser.Model.Events;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Session;
+using Microsoft.Extensions.Caching.Memory;
 
 namespace Emby.Server.Implementations.Devices
 {
     public class DeviceManager : IDeviceManager
     {
+        private readonly IMemoryCache _memoryCache;
         private readonly IJsonSerializer _json;
         private readonly IUserManager _userManager;
         private readonly IServerConfigurationManager _config;
         private readonly IAuthenticationRepository _authRepo;
-        private readonly Dictionary<string, ClientCapabilities> _capabilitiesCache;
         private readonly object _capabilitiesSyncLock = new object();
 
         public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
@@ -35,13 +36,14 @@ namespace Emby.Server.Implementations.Devices
             IAuthenticationRepository authRepo,
             IJsonSerializer json,
             IUserManager userManager,
-            IServerConfigurationManager config)
+            IServerConfigurationManager config,
+            IMemoryCache memoryCache)
         {
             _json = json;
             _userManager = userManager;
             _config = config;
+            _memoryCache = memoryCache;
             _authRepo = authRepo;
-            _capabilitiesCache = new Dictionary<string, ClientCapabilities>(StringComparer.OrdinalIgnoreCase);
         }
 
         public void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
@@ -51,8 +53,7 @@ namespace Emby.Server.Implementations.Devices
 
             lock (_capabilitiesSyncLock)
             {
-                _capabilitiesCache[deviceId] = capabilities;
-
+                _memoryCache.Set(deviceId, capabilities);
                 _json.SerializeToFile(capabilities, path);
             }
         }
@@ -71,13 +72,13 @@ namespace Emby.Server.Implementations.Devices
 
         public ClientCapabilities GetCapabilities(string id)
         {
-            lock (_capabilitiesSyncLock)
+            if (_memoryCache.TryGetValue(id, out ClientCapabilities result))
             {
-                if (_capabilitiesCache.TryGetValue(id, out var result))
-                {
-                    return result;
-                }
+                return result;
+            }
 
+            lock (_capabilitiesSyncLock)
+            {
                 var path = Path.Combine(GetDevicePath(id), "capabilities.json");
                 try
                 {

+ 5 - 7
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" />
@@ -25,7 +23,7 @@
 
   <ItemGroup>
     <PackageReference Include="IPNetwork2" Version="2.5.211" />
-    <PackageReference Include="Jellyfin.XmlTv" Version="10.6.0-pre1" />
+    <PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" />
     <PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.2.7" />
     <PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" />
     <PackageReference Include="Microsoft.AspNetCore.Hosting.Server.Abstractions" Version="2.2.0" />
@@ -38,10 +36,10 @@
     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.6" />
     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.6" />
     <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="3.1.6" />
-    <PackageReference Include="Mono.Nat" Version="2.0.1" />
+    <PackageReference Include="Mono.Nat" Version="2.0.2" />
     <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.3.1" />
-    <PackageReference Include="ServiceStack.Text.Core" Version="5.9.0" />
-    <PackageReference Include="sharpcompress" Version="0.25.1" />
+    <PackageReference Include="ServiceStack.Text.Core" Version="5.9.2" />
+    <PackageReference Include="sharpcompress" Version="0.26.0" />
     <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" />
     <PackageReference Include="DotNet.Glob" Version="3.0.9" />
   </ItemGroup>
@@ -54,7 +52,7 @@
     <TargetFramework>netstandard2.1</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'" >true</TreatWarningsAsErrors>
+    <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors>
   </PropertyGroup>
 
   <!-- Code Analyzers-->

+ 41 - 52
Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs

@@ -23,10 +23,12 @@ namespace Emby.Server.Implementations.EntryPoints
     public class LibraryChangedNotifier : IServerEntryPoint
     {
         /// <summary>
-        /// The library manager.
+        /// The library update duration.
         /// </summary>
-        private readonly ILibraryManager _libraryManager;
+        private const int LibraryUpdateDuration = 30000;
 
+        private readonly ILibraryManager _libraryManager;
+        private readonly IProviderManager _providerManager;
         private readonly ISessionManager _sessionManager;
         private readonly IUserManager _userManager;
         private readonly ILogger<LibraryChangedNotifier> _logger;
@@ -38,23 +40,10 @@ namespace Emby.Server.Implementations.EntryPoints
 
         private readonly List<Folder> _foldersAddedTo = new List<Folder>();
         private readonly List<Folder> _foldersRemovedFrom = new List<Folder>();
-
         private readonly List<BaseItem> _itemsAdded = new List<BaseItem>();
         private readonly List<BaseItem> _itemsRemoved = new List<BaseItem>();
         private readonly List<BaseItem> _itemsUpdated = new List<BaseItem>();
-
-        /// <summary>
-        /// Gets or sets the library update timer.
-        /// </summary>
-        /// <value>The library update timer.</value>
-        private Timer LibraryUpdateTimer { get; set; }
-
-        /// <summary>
-        /// The library update duration.
-        /// </summary>
-        private const int LibraryUpdateDuration = 30000;
-
-        private readonly IProviderManager _providerManager;
+        private readonly Dictionary<Guid, DateTime> _lastProgressMessageTimes = new Dictionary<Guid, DateTime>();
 
         public LibraryChangedNotifier(
             ILibraryManager libraryManager,
@@ -70,22 +59,26 @@ namespace Emby.Server.Implementations.EntryPoints
             _providerManager = providerManager;
         }
 
+        /// <summary>
+        /// Gets or sets the library update timer.
+        /// </summary>
+        /// <value>The library update timer.</value>
+        private Timer LibraryUpdateTimer { get; set; }
+
         public Task RunAsync()
         {
-            _libraryManager.ItemAdded += libraryManager_ItemAdded;
-            _libraryManager.ItemUpdated += libraryManager_ItemUpdated;
-            _libraryManager.ItemRemoved += libraryManager_ItemRemoved;
+            _libraryManager.ItemAdded += OnLibraryItemAdded;
+            _libraryManager.ItemUpdated += OnLibraryItemUpdated;
+            _libraryManager.ItemRemoved += OnLibraryItemRemoved;
 
-            _providerManager.RefreshCompleted += _providerManager_RefreshCompleted;
-            _providerManager.RefreshStarted += _providerManager_RefreshStarted;
-            _providerManager.RefreshProgress += _providerManager_RefreshProgress;
+            _providerManager.RefreshCompleted += OnProviderRefreshCompleted;
+            _providerManager.RefreshStarted += OnProviderRefreshStarted;
+            _providerManager.RefreshProgress += OnProviderRefreshProgress;
 
             return Task.CompletedTask;
         }
 
-        private Dictionary<Guid, DateTime> _lastProgressMessageTimes = new Dictionary<Guid, DateTime>();
-
-        private void _providerManager_RefreshProgress(object sender, GenericEventArgs<Tuple<BaseItem, double>> e)
+        private void OnProviderRefreshProgress(object sender, GenericEventArgs<Tuple<BaseItem, double>> e)
         {
             var item = e.Argument.Item1;
 
@@ -122,9 +115,11 @@ namespace Emby.Server.Implementations.EntryPoints
 
             foreach (var collectionFolder in collectionFolders)
             {
-                var collectionFolderDict = new Dictionary<string, string>();
-                collectionFolderDict["ItemId"] = collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture);
-                collectionFolderDict["Progress"] = (collectionFolder.GetRefreshProgress() ?? 0).ToString(CultureInfo.InvariantCulture);
+                var collectionFolderDict = new Dictionary<string, string>
+                {
+                    ["ItemId"] = collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture),
+                    ["Progress"] = (collectionFolder.GetRefreshProgress() ?? 0).ToString(CultureInfo.InvariantCulture)
+                };
 
                 try
                 {
@@ -136,21 +131,19 @@ namespace Emby.Server.Implementations.EntryPoints
             }
         }
 
-        private void _providerManager_RefreshStarted(object sender, GenericEventArgs<BaseItem> e)
+        private void OnProviderRefreshStarted(object sender, GenericEventArgs<BaseItem> e)
         {
-            _providerManager_RefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 0)));
+            OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 0)));
         }
 
-        private void _providerManager_RefreshCompleted(object sender, GenericEventArgs<BaseItem> e)
+        private void OnProviderRefreshCompleted(object sender, GenericEventArgs<BaseItem> e)
         {
-            _providerManager_RefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100)));
+            OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100)));
         }
 
         private static bool EnableRefreshMessage(BaseItem item)
         {
-            var folder = item as Folder;
-
-            if (folder == null)
+            if (!(item is Folder folder))
             {
                 return false;
             }
@@ -183,7 +176,7 @@ namespace Emby.Server.Implementations.EntryPoints
         /// </summary>
         /// <param name="sender">The source of the event.</param>
         /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param>
-        void libraryManager_ItemAdded(object sender, ItemChangeEventArgs e)
+        private void OnLibraryItemAdded(object sender, ItemChangeEventArgs e)
         {
             if (!FilterItem(e.Item))
             {
@@ -205,8 +198,7 @@ namespace Emby.Server.Implementations.EntryPoints
                     LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite);
                 }
 
-                var parent = e.Item.GetParent() as Folder;
-                if (parent != null)
+                if (e.Item.GetParent() is Folder parent)
                 {
                     _foldersAddedTo.Add(parent);
                 }
@@ -220,7 +212,7 @@ namespace Emby.Server.Implementations.EntryPoints
         /// </summary>
         /// <param name="sender">The source of the event.</param>
         /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param>
-        void libraryManager_ItemUpdated(object sender, ItemChangeEventArgs e)
+        private void OnLibraryItemUpdated(object sender, ItemChangeEventArgs e)
         {
             if (!FilterItem(e.Item))
             {
@@ -231,8 +223,7 @@ namespace Emby.Server.Implementations.EntryPoints
             {
                 if (LibraryUpdateTimer == null)
                 {
-                    LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration,
-                                                   Timeout.Infinite);
+                    LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, Timeout.Infinite);
                 }
                 else
                 {
@@ -248,7 +239,7 @@ namespace Emby.Server.Implementations.EntryPoints
         /// </summary>
         /// <param name="sender">The source of the event.</param>
         /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param>
-        void libraryManager_ItemRemoved(object sender, ItemChangeEventArgs e)
+        private void OnLibraryItemRemoved(object sender, ItemChangeEventArgs e)
         {
             if (!FilterItem(e.Item))
             {
@@ -259,16 +250,14 @@ namespace Emby.Server.Implementations.EntryPoints
             {
                 if (LibraryUpdateTimer == null)
                 {
-                    LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration,
-                                                   Timeout.Infinite);
+                    LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, Timeout.Infinite);
                 }
                 else
                 {
                     LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite);
                 }
 
-                var parent = e.Parent as Folder;
-                if (parent != null)
+                if (e.Parent is Folder parent)
                 {
                     _foldersRemovedFrom.Add(parent);
                 }
@@ -486,13 +475,13 @@ namespace Emby.Server.Implementations.EntryPoints
                     LibraryUpdateTimer = null;
                 }
 
-                _libraryManager.ItemAdded -= libraryManager_ItemAdded;
-                _libraryManager.ItemUpdated -= libraryManager_ItemUpdated;
-                _libraryManager.ItemRemoved -= libraryManager_ItemRemoved;
+                _libraryManager.ItemAdded -= OnLibraryItemAdded;
+                _libraryManager.ItemUpdated -= OnLibraryItemUpdated;
+                _libraryManager.ItemRemoved -= OnLibraryItemRemoved;
 
-                _providerManager.RefreshCompleted -= _providerManager_RefreshCompleted;
-                _providerManager.RefreshStarted -= _providerManager_RefreshStarted;
-                _providerManager.RefreshProgress -= _providerManager_RefreshProgress;
+                _providerManager.RefreshCompleted -= OnProviderRefreshCompleted;
+                _providerManager.RefreshStarted -= OnProviderRefreshStarted;
+                _providerManager.RefreshProgress -= OnProviderRefreshProgress;
             }
         }
     }

+ 3 - 3
Emby.Server.Implementations/HttpServer/HttpListenerHost.cs

@@ -449,7 +449,7 @@ namespace Emby.Server.Implementations.HttpServer
                 if (string.Equals(httpReq.Verb, "OPTIONS", StringComparison.OrdinalIgnoreCase))
                 {
                     httpRes.StatusCode = 200;
-                    foreach(var (key, value) in GetDefaultCorsHeaders(httpReq))
+                    foreach (var (key, value) in GetDefaultCorsHeaders(httpReq))
                     {
                         httpRes.Headers.Add(key, value);
                     }
@@ -486,7 +486,7 @@ namespace Emby.Server.Implementations.HttpServer
                 var handler = GetServiceHandler(httpReq);
                 if (handler != null)
                 {
-                    await handler.ProcessRequestAsync(this, httpReq, httpRes, _logger, cancellationToken).ConfigureAwait(false);
+                    await handler.ProcessRequestAsync(this, httpReq, httpRes, cancellationToken).ConfigureAwait(false);
                 }
                 else
                 {
@@ -567,7 +567,7 @@ namespace Emby.Server.Implementations.HttpServer
 
                 WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
 
-                var connection = new WebSocketConnection(
+                using var connection = new WebSocketConnection(
                     _loggerFactory.CreateLogger<WebSocketConnection>(),
                     webSocket,
                     context.Connection.RemoteIpAddress,

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

+ 4 - 4
Emby.Server.Implementations/HttpServer/WebSocketConnection.cs

@@ -19,7 +19,7 @@ namespace Emby.Server.Implementations.HttpServer
     /// <summary>
     /// Class WebSocketConnection.
     /// </summary>
-    public class WebSocketConnection : IWebSocketConnection
+    public class WebSocketConnection : IWebSocketConnection, IDisposable
     {
         /// <summary>
         /// The logger.
@@ -119,7 +119,7 @@ namespace Emby.Server.Implementations.HttpServer
                 Memory<byte> memory = writer.GetMemory(512);
                 try
                 {
-                    receiveresult = await _socket.ReceiveAsync(memory, cancellationToken);
+                    receiveresult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
                 }
                 catch (WebSocketException ex)
                 {
@@ -137,7 +137,7 @@ namespace Emby.Server.Implementations.HttpServer
                 writer.Advance(bytesRead);
 
                 // Make the data available to the PipeReader
-                FlushResult flushResult = await writer.FlushAsync();
+                FlushResult flushResult = await writer.FlushAsync().ConfigureAwait(false);
                 if (flushResult.IsCompleted)
                 {
                     // The PipeReader stopped reading
@@ -223,7 +223,7 @@ namespace Emby.Server.Implementations.HttpServer
 
             if (info.MessageType.Equals("KeepAlive", StringComparison.Ordinal))
             {
-                await SendKeepAliveResponse();
+                await SendKeepAliveResponse().ConfigureAwait(false);
             }
             else
             {

+ 2 - 1
Emby.Server.Implementations/IO/FileRefresher.cs

@@ -21,6 +21,7 @@ namespace Emby.Server.Implementations.IO
         private readonly List<string> _affectedPaths = new List<string>();
         private readonly object _timerLock = new object();
         private Timer _timer;
+        private bool _disposed;
 
         public FileRefresher(string path, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger logger)
         {
@@ -213,11 +214,11 @@ namespace Emby.Server.Implementations.IO
             }
         }
 
-        private bool _disposed;
         public void Dispose()
         {
             _disposed = true;
             DisposeTimer();
+            GC.SuppressFinalize(this);
         }
     }
 }

+ 1 - 1
Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs

@@ -3,7 +3,7 @@
 using System;
 using System.Collections.Generic;
 using System.IO;
-using Emby.Server.Implementations.Images;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;

+ 1 - 1
Emby.Server.Implementations/Images/FolderImageProvider.cs

@@ -1,7 +1,7 @@
 #pragma warning disable CS1591
 
 using System.Collections.Generic;
-using Emby.Server.Implementations.Images;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;

+ 1 - 0
Emby.Server.Implementations/Images/GenreImageProvider.cs

@@ -1,6 +1,7 @@
 #pragma warning disable CS1591
 
 using System.Collections.Generic;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;

+ 20 - 3
Emby.Server.Implementations/Library/IgnorePatterns.cs

@@ -18,7 +18,21 @@ namespace Emby.Server.Implementations.Library
         {
             "**/small.jpg",
             "**/albumart.jpg",
-            "**/*sample*",
+
+            // We have neither non-greedy matching or character group repetitions, working around that here.
+            // https://github.com/dazinator/DotNet.Glob#patterns
+            // .*/sample\..{1,5}
+            "**/sample.?",
+            "**/sample.??",
+            "**/sample.???", // Matches sample.mkv
+            "**/sample.????", // Matches sample.webm
+            "**/sample.?????",
+            "**/*.sample.?",
+            "**/*.sample.??",
+            "**/*.sample.???",
+            "**/*.sample.????",
+            "**/*.sample.?????",
+            "**/sample/*",
 
             // Directories
             "**/metadata/**",
@@ -64,10 +78,13 @@ namespace Emby.Server.Implementations.Library
             "**/.grab/**",
             "**/.grab",
 
-            // Unix hidden files and directories
-            "**/.*/**",
+            // Unix hidden files
             "**/.*",
 
+            // Mac - if you ever remove the above.
+            // "**/._*",
+            // "**/.DS_Store",
+
             // thumbs.db
             "**/thumbs.db",
 

+ 9 - 10
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -1,7 +1,6 @@
 #pragma warning disable CS1591
 
 using System;
-using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
@@ -46,11 +45,11 @@ using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Tasks;
 using MediaBrowser.Providers.MediaInfo;
+using Microsoft.Extensions.Caching.Memory;
 using Microsoft.Extensions.Logging;
 using Episode = MediaBrowser.Controller.Entities.TV.Episode;
 using Genre = MediaBrowser.Controller.Entities.Genre;
 using Person = MediaBrowser.Controller.Entities.Person;
-using SortOrder = MediaBrowser.Model.Entities.SortOrder;
 using VideoResolver = Emby.Naming.Video.VideoResolver;
 
 namespace Emby.Server.Implementations.Library
@@ -63,6 +62,7 @@ namespace Emby.Server.Implementations.Library
         private const string ShortcutFileExtension = ".mblink";
 
         private readonly ILogger<LibraryManager> _logger;
+        private readonly IMemoryCache _memoryCache;
         private readonly ITaskManager _taskManager;
         private readonly IUserManager _userManager;
         private readonly IUserDataManager _userDataRepository;
@@ -74,7 +74,6 @@ namespace Emby.Server.Implementations.Library
         private readonly IMediaEncoder _mediaEncoder;
         private readonly IFileSystem _fileSystem;
         private readonly IItemRepository _itemRepository;
-        private readonly ConcurrentDictionary<Guid, BaseItem> _libraryItemsCache;
         private readonly IImageProcessor _imageProcessor;
 
         /// <summary>
@@ -112,6 +111,7 @@ namespace Emby.Server.Implementations.Library
         /// <param name="mediaEncoder">The media encoder.</param>
         /// <param name="itemRepository">The item repository.</param>
         /// <param name="imageProcessor">The image processor.</param>
+        /// <param name="memoryCache">The memory cache.</param>
         public LibraryManager(
             IServerApplicationHost appHost,
             ILogger<LibraryManager> logger,
@@ -125,7 +125,8 @@ namespace Emby.Server.Implementations.Library
             Lazy<IUserViewManager> userviewManagerFactory,
             IMediaEncoder mediaEncoder,
             IItemRepository itemRepository,
-            IImageProcessor imageProcessor)
+            IImageProcessor imageProcessor,
+            IMemoryCache memoryCache)
         {
             _appHost = appHost;
             _logger = logger;
@@ -140,8 +141,7 @@ namespace Emby.Server.Implementations.Library
             _mediaEncoder = mediaEncoder;
             _itemRepository = itemRepository;
             _imageProcessor = imageProcessor;
-
-            _libraryItemsCache = new ConcurrentDictionary<Guid, BaseItem>();
+            _memoryCache = memoryCache;
 
             _configurationManager.ConfigurationUpdated += ConfigurationUpdated;
 
@@ -299,7 +299,7 @@ namespace Emby.Server.Implementations.Library
                 }
             }
 
-            _libraryItemsCache.AddOrUpdate(item.Id, item, delegate { return item; });
+            _memoryCache.Set(item.Id, item);
         }
 
         public void DeleteItem(BaseItem item, DeleteOptions options)
@@ -447,7 +447,7 @@ namespace Emby.Server.Implementations.Library
                 _itemRepository.DeleteItem(child.Id);
             }
 
-            _libraryItemsCache.TryRemove(item.Id, out BaseItem removed);
+            _memoryCache.Remove(item.Id);
 
             ReportItemRemoved(item, parent);
         }
@@ -1248,7 +1248,7 @@ namespace Emby.Server.Implementations.Library
                 throw new ArgumentException("Guid can't be empty", nameof(id));
             }
 
-            if (_libraryItemsCache.TryGetValue(id, out BaseItem item))
+            if (_memoryCache.TryGetValue(id, out BaseItem item))
             {
                 return item;
             }
@@ -1591,7 +1591,6 @@ namespace Emby.Server.Implementations.Library
         public async Task<IEnumerable<Video>> GetIntros(BaseItem item, User user)
         {
             var tasks = IntroProviders
-                .OrderBy(i => i.GetType().Name.Contains("Default", StringComparison.OrdinalIgnoreCase) ? 1 : 0)
                 .Take(1)
                 .Select(i => GetIntros(i, item, user));
 

+ 15 - 18
Emby.Server.Implementations/Library/MediaSourceManager.cs

@@ -46,8 +46,6 @@ namespace Emby.Server.Implementations.Library
         private readonly Dictionary<string, ILiveStream> _openStreams = new Dictionary<string, ILiveStream>(StringComparer.OrdinalIgnoreCase);
         private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1);
 
-        private readonly object _disposeLock = new object();
-
         private IMediaSourceProvider[] _providers;
 
         public MediaSourceManager(
@@ -623,12 +621,14 @@ namespace Emby.Server.Implementations.Library
 
             if (liveStreamInfo is IDirectStreamProvider)
             {
-                var info = await _mediaEncoder.GetMediaInfo(new MediaInfoRequest
-                {
-                    MediaSource = mediaSource,
-                    ExtractChapters = false,
-                    MediaType = DlnaProfileType.Video
-                }, cancellationToken).ConfigureAwait(false);
+                var info = await _mediaEncoder.GetMediaInfo(
+                    new MediaInfoRequest
+                    {
+                        MediaSource = mediaSource,
+                        ExtractChapters = false,
+                        MediaType = DlnaProfileType.Video
+                    },
+                    cancellationToken).ConfigureAwait(false);
 
                 mediaSource.MediaStreams = info.MediaStreams;
                 mediaSource.Container = info.Container;
@@ -859,11 +859,11 @@ namespace Emby.Server.Implementations.Library
             }
         }
 
-        private Tuple<IMediaSourceProvider, string> GetProvider(string key)
+        private (IMediaSourceProvider, string) GetProvider(string key)
         {
             if (string.IsNullOrEmpty(key))
             {
-                throw new ArgumentException("key");
+                throw new ArgumentException("Key can't be empty.", nameof(key));
             }
 
             var keys = key.Split(new[] { LiveStreamIdDelimeter }, 2);
@@ -873,7 +873,7 @@ namespace Emby.Server.Implementations.Library
             var splitIndex = key.IndexOf(LiveStreamIdDelimeter, StringComparison.Ordinal);
             var keyId = key.Substring(splitIndex + 1);
 
-            return new Tuple<IMediaSourceProvider, string>(provider, keyId);
+            return (provider, keyId);
         }
 
         /// <summary>
@@ -893,15 +893,12 @@ namespace Emby.Server.Implementations.Library
         {
             if (dispose)
             {
-                lock (_disposeLock)
+                foreach (var key in _openStreams.Keys.ToList())
                 {
-                    foreach (var key in _openStreams.Keys.ToList())
-                    {
-                        var task = CloseLiveStream(key);
-
-                        Task.WaitAll(task);
-                    }
+                    CloseLiveStream(key).GetAwaiter().GetResult();
                 }
+
+                _liveStreamSemaphore.Dispose();
             }
         }
     }

+ 1 - 1
Emby.Server.Implementations/Library/MusicManager.cs

@@ -4,12 +4,12 @@ using System;
 using System.Collections.Generic;
 using System.Linq;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Playlists;
-using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
 

+ 1 - 1
Emby.Server.Implementations/Library/SearchEngine.cs

@@ -4,12 +4,12 @@ using System;
 using System.Collections.Generic;
 using System.Linq;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Extensions;
 using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Search;
 using Microsoft.Extensions.Logging;

+ 1 - 0
Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs

@@ -12,6 +12,7 @@ using System.Threading;
 using System.Threading.Tasks;
 using System.Xml;
 using Emby.Server.Implementations.Library;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;

+ 6 - 5
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs

@@ -77,7 +77,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
         }
     }
 
-    public class HdHomerunManager : IDisposable
+    public sealed class HdHomerunManager : IDisposable
     {
         public const int HdHomeRunPort = 65001;
 
@@ -105,6 +105,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                     StopStreaming(socket).GetAwaiter().GetResult();
                 }
             }
+
+            GC.SuppressFinalize(this);
         }
 
         public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken)
@@ -162,7 +164,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                     }
 
                     _activeTuner = i;
-                    var lockKeyString = string.Format("{0:d}", lockKeyValue);
+                    var lockKeyString = string.Format(CultureInfo.InvariantCulture, "{0:d}", lockKeyValue);
                     var lockkeyMsg = CreateSetMessage(i, "lockkey", lockKeyString, null);
                     await stream.WriteAsync(lockkeyMsg, 0, lockkeyMsg.Length, cancellationToken).ConfigureAwait(false);
                     int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
@@ -173,8 +175,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                         continue;
                     }
 
-                    var commandList = commands.GetCommands();
-                    foreach (var command in commandList)
+                    foreach (var command in commands.GetCommands())
                     {
                         var channelMsg = CreateSetMessage(i, command.Item1, command.Item2, lockKeyValue);
                         await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false);
@@ -188,7 +189,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                         }
                     }
 
-                    var targetValue = string.Format("rtp://{0}:{1}", localIp, localPort);
+                    var targetValue = string.Format(CultureInfo.InvariantCulture, "rtp://{0}:{1}", localIp, localPort);
                     var targetMsg = CreateSetMessage(i, "target", targetValue, lockKeyValue);
 
                     await stream.WriteAsync(targetMsg, 0, targetMsg.Length, cancellationToken).ConfigureAwait(false);

+ 2 - 2
Emby.Server.Implementations/Localization/Core/bn.json

@@ -7,7 +7,7 @@
     "CameraImageUploadedFrom": "একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে {0} থেকে",
     "Books": "বই",
     "AuthenticationSucceededWithUserName": "{0} যাচাই সফল",
-    "Artists": "শিল্পী",
+    "Artists": "শিল্পীরা",
     "Application": "অ্যাপ্লিকেশন",
     "Albums": "অ্যালবামগুলো",
     "HeaderFavoriteEpisodes": "প্রিব পর্বগুলো",
@@ -19,7 +19,7 @@
     "Genres": "ঘরানা",
     "Folders": "ফোল্ডারগুলো",
     "Favorites": "ফেভারিটগুলো",
-    "FailedLoginAttemptWithUserName": "{0} থেকে লগিন করতে ব্যর্থ",
+    "FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে",
     "AppDeviceValues": "এপ: {0}, ডিভাইস: {0}",
     "VersionNumber": "সংস্করণ {0}",
     "ValueSpecialEpisodeName": "বিশেষ - {0}",

+ 4 - 4
Emby.Server.Implementations/Localization/Core/de.json

@@ -5,7 +5,7 @@
     "Artists": "Interpreten",
     "AuthenticationSucceededWithUserName": "{0} hat sich erfolgreich angemeldet",
     "Books": "Bücher",
-    "CameraImageUploadedFrom": "Ein neues Foto wurde von {0} hochgeladen",
+    "CameraImageUploadedFrom": "Ein neues Kamerafoto wurde von {0} hochgeladen",
     "Channels": "Kanäle",
     "ChapterNameValue": "Kapitel {0}",
     "Collections": "Sammlungen",
@@ -101,12 +101,12 @@
     "TaskCleanTranscode": "Lösche Transkodier Pfad",
     "TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche dazu eingestellt sind automatisch zu updaten und installiert sie.",
     "TaskUpdatePlugins": "Update Plugins",
-    "TaskRefreshPeopleDescription": "Erneuert Metadaten für Schausteller und Regisseure in deinen Bibliotheken.",
-    "TaskRefreshPeople": "Erneuere Schausteller",
+    "TaskRefreshPeopleDescription": "Erneuert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.",
+    "TaskRefreshPeople": "Erneuere Schauspieler",
     "TaskCleanLogsDescription": "Lösche Log Dateien die älter als {0} Tage sind.",
     "TaskCleanLogs": "Lösche Log Pfad",
     "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken für hinzugefügte Datein und erneuere Metadaten.",
-    "TaskRefreshLibrary": "Scanne alle Bibliotheken",
+    "TaskRefreshLibrary": "Scanne Medien-Bibliothek",
     "TaskRefreshChapterImagesDescription": "Kreiert Vorschaubilder für Videos welche Kapitel haben.",
     "TaskRefreshChapterImages": "Extrahiert Kapitel-Bilder",
     "TaskCleanCacheDescription": "Löscht Zwischenspeicherdatein die nicht länger von System gebraucht werden.",

+ 28 - 28
Emby.Server.Implementations/Localization/Core/he.json

@@ -18,13 +18,13 @@
     "HeaderAlbumArtists": "אמני האלבום",
     "HeaderCameraUploads": "העלאות ממצלמה",
     "HeaderContinueWatching": "המשך לצפות",
-    "HeaderFavoriteAlbums": "אלבומים שאהבתי",
+    "HeaderFavoriteAlbums": "אלבומים מועדפים",
     "HeaderFavoriteArtists": "אמנים מועדפים",
     "HeaderFavoriteEpisodes": "פרקים מועדפים",
-    "HeaderFavoriteShows": "סדרות מועדפות",
+    "HeaderFavoriteShows": "תוכניות מועדפות",
     "HeaderFavoriteSongs": "שירים מועדפים",
     "HeaderLiveTV": "שידורים חיים",
-    "HeaderNextUp": "הבא",
+    "HeaderNextUp": "הבא בתור",
     "HeaderRecordingGroups": "קבוצות הקלטה",
     "HomeVideos": "סרטונים בייתים",
     "Inherit": "הורש",
@@ -45,37 +45,37 @@
     "NameSeasonNumber": "עונה {0}",
     "NameSeasonUnknown": "עונה לא ידועה",
     "NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.",
-    "NotificationOptionApplicationUpdateAvailable": "Application update available",
-    "NotificationOptionApplicationUpdateInstalled": "Application update installed",
-    "NotificationOptionAudioPlayback": "Audio playback started",
-    "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
-    "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+    "NotificationOptionApplicationUpdateAvailable": "קיים עדכון זמין ליישום",
+    "NotificationOptionApplicationUpdateInstalled": "עדכון ליישום הותקן",
+    "NotificationOptionAudioPlayback": "ניגון שמע החל",
+    "NotificationOptionAudioPlaybackStopped": "ניגון שמע הופסק",
+    "NotificationOptionCameraImageUploaded": "תמונת מצלמה הועלתה",
     "NotificationOptionInstallationFailed": "התקנה נכשלה",
-    "NotificationOptionNewLibraryContent": "New content added",
-    "NotificationOptionPluginError": "Plugin failure",
+    "NotificationOptionNewLibraryContent": "תוכן חדש הוסף",
+    "NotificationOptionPluginError": "כשלון בתוסף",
     "NotificationOptionPluginInstalled": "התוסף הותקן",
     "NotificationOptionPluginUninstalled": "התוסף הוסר",
     "NotificationOptionPluginUpdateInstalled": "העדכון לתוסף הותקן",
     "NotificationOptionServerRestartRequired": "יש לאתחל את השרת",
-    "NotificationOptionTaskFailed": "Scheduled task failure",
-    "NotificationOptionUserLockedOut": "User locked out",
-    "NotificationOptionVideoPlayback": "Video playback started",
-    "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+    "NotificationOptionTaskFailed": "משימה מתוזמנת נכשלה",
+    "NotificationOptionUserLockedOut": "משתמש ננעל",
+    "NotificationOptionVideoPlayback": "ניגון וידאו החל",
+    "NotificationOptionVideoPlaybackStopped": "ניגון וידאו הופסק",
     "Photos": "תמונות",
     "Playlists": "רשימות הפעלה",
     "Plugin": "Plugin",
-    "PluginInstalledWithName": "{0} was installed",
-    "PluginUninstalledWithName": "{0} was uninstalled",
-    "PluginUpdatedWithName": "{0} was updated",
+    "PluginInstalledWithName": "{0} הותקן",
+    "PluginUninstalledWithName": "{0} הוסר",
+    "PluginUpdatedWithName": "{0} עודכן",
     "ProviderValue": "Provider: {0}",
-    "ScheduledTaskFailedWithName": "{0} failed",
-    "ScheduledTaskStartedWithName": "{0} started",
-    "ServerNameNeedsToBeRestarted": "{0} needs to be restarted",
+    "ScheduledTaskFailedWithName": "{0} נכשל",
+    "ScheduledTaskStartedWithName": "{0} החל",
+    "ServerNameNeedsToBeRestarted": "{0} דורש הפעלה מחדש",
     "Shows": "סדרות",
     "Songs": "שירים",
     "StartupEmbyServerIsLoading": "שרת Jellyfin בהליכי טעינה. אנא נסה שנית בעוד זמן קצר.",
     "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
-    "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}",
+    "SubtitleDownloadFailureFromForItem": "הורדת כתוביות נכשלה מ-{0} עבור {1}",
     "Sync": "סנכרן",
     "System": "System",
     "TvShows": "סדרות טלוויזיה",
@@ -83,14 +83,14 @@
     "UserCreatedWithName": "המשתמש {0} נוצר",
     "UserDeletedWithName": "המשתמש {0} הוסר",
     "UserDownloadingItemWithValues": "{0} מוריד את {1}",
-    "UserLockedOutWithName": "User {0} has been locked out",
-    "UserOfflineFromDevice": "{0} has disconnected from {1}",
-    "UserOnlineFromDevice": "{0} is online from {1}",
-    "UserPasswordChangedWithName": "Password has been changed for user {0}",
-    "UserPolicyUpdatedWithName": "User policy has been updated for {0}",
+    "UserLockedOutWithName": "המשתמש {0} ננעל",
+    "UserOfflineFromDevice": "{0} התנתק מ-{1}",
+    "UserOnlineFromDevice": "{0} מחובר מ-{1}",
+    "UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}",
+    "UserPolicyUpdatedWithName": "מדיניות המשתמש {0} עודכנה",
     "UserStartedPlayingItemWithValues": "{0} מנגן את {1} על {2}",
     "UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} על {2}",
-    "ValueHasBeenAddedToLibrary": "{0} has been added to your media library",
+    "ValueHasBeenAddedToLibrary": "{0} התווסף לספריית המדיה שלך",
     "ValueSpecialEpisodeName": "מיוחד- {0}",
     "VersionNumber": "Version {0}",
     "TaskRefreshLibrary": "סרוק ספריית מדיה",
@@ -109,7 +109,7 @@
     "TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות לסרטונים שיש להם פרקים.",
     "TasksChannelsCategory": "ערוצי אינטרנט",
     "TaskDownloadMissingSubtitlesDescription": "חפש באינטרנט עבור הכתוביות החסרות בהתבסס על המטה-דיאטה.",
-    "TaskDownloadMissingSubtitles": "הורד כתוביות חסרות.",
+    "TaskDownloadMissingSubtitles": "הורד כתוביות חסרות",
     "TaskRefreshChannelsDescription": "רענן פרטי ערוץ אינטרנטי.",
     "TaskRefreshChannels": "רענן ערוץ",
     "TaskCleanTranscodeDescription": "מחק קבצי transcode שנוצרו מלפני יותר מיום.",

+ 2 - 2
Emby.Server.Implementations/Localization/Core/it.json

@@ -84,8 +84,8 @@
     "UserDeletedWithName": "L'utente {0} è stato rimosso",
     "UserDownloadingItemWithValues": "{0} sta scaricando {1}",
     "UserLockedOutWithName": "L'utente {0} è stato bloccato",
-    "UserOfflineFromDevice": "{0} è stato disconnesso da {1}",
-    "UserOnlineFromDevice": "{0} è online da {1}",
+    "UserOfflineFromDevice": "{0} si è disconnesso su {1}",
+    "UserOnlineFromDevice": "{0} è online su {1}",
     "UserPasswordChangedWithName": "La password è stata cambiata per l'utente {0}",
     "UserPolicyUpdatedWithName": "La policy dell'utente è stata aggiornata per {0}",
     "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di {1} su {2}",

+ 94 - 13
Emby.Server.Implementations/Localization/Core/uk.json

@@ -1,13 +1,13 @@
 {
-    "MusicVideos": "Музичні відео",
+    "MusicVideos": "Музичні кліпи",
     "Music": "Музика",
     "Movies": "Фільми",
-    "MessageApplicationUpdatedTo": "Jellyfin Server був оновлений до версії {0}",
-    "MessageApplicationUpdated": "Jellyfin Server був оновлений",
+    "MessageApplicationUpdatedTo": "Jellyfin Server оновлено до версії {0}",
+    "MessageApplicationUpdated": "Jellyfin Server оновлено",
     "Latest": "Останні",
-    "LabelIpAddressValue": "IP-адреси: {0}",
-    "ItemRemovedWithName": "{0} видалено з бібліотеки",
-    "ItemAddedWithName": "{0} додано до бібліотеки",
+    "LabelIpAddressValue": "IP-адреса: {0}",
+    "ItemRemovedWithName": "{0} видалено з медіатеки",
+    "ItemAddedWithName": "{0} додано до медіатеки",
     "HeaderNextUp": "Наступний",
     "HeaderLiveTV": "Ефірне ТБ",
     "HeaderFavoriteSongs": "Улюблені пісні",
@@ -17,20 +17,101 @@
     "HeaderFavoriteAlbums": "Улюблені альбоми",
     "HeaderContinueWatching": "Продовжити перегляд",
     "HeaderCameraUploads": "Завантажено з камери",
-    "HeaderAlbumArtists": "Виконавці альбомів",
+    "HeaderAlbumArtists": "Виконавці альбому",
     "Genres": "Жанри",
-    "Folders": "Директорії",
+    "Folders": "Каталоги",
     "Favorites": "Улюблені",
-    "DeviceOnlineWithName": "{0} під'єднано",
-    "DeviceOfflineWithName": "{0} від'єднано",
+    "DeviceOnlineWithName": "Пристрій {0} підключився",
+    "DeviceOfflineWithName": "Пристрій {0} відключився",
     "Collections": "Колекції",
-    "ChapterNameValue": "Глава {0}",
+    "ChapterNameValue": "Розділ {0}",
     "Channels": "Канали",
     "CameraImageUploadedFrom": "Нова фотографія завантажена з {0}",
     "Books": "Книги",
-    "AuthenticationSucceededWithUserName": "{0} успішно авторизовані",
+    "AuthenticationSucceededWithUserName": "{0} успішно авторизований",
     "Artists": "Виконавці",
     "Application": "Додаток",
     "AppDeviceValues": "Додаток: {0}, Пристрій: {1}",
-    "Albums": "Альбоми"
+    "Albums": "Альбоми",
+    "NotificationOptionServerRestartRequired": "Необхідно перезапустити сервер",
+    "NotificationOptionPluginUpdateInstalled": "Встановлено оновлення плагіна",
+    "NotificationOptionPluginUninstalled": "Плагін видалено",
+    "NotificationOptionPluginInstalled": "Плагін встановлено",
+    "NotificationOptionPluginError": "Помилка плагіна",
+    "NotificationOptionNewLibraryContent": "Додано новий контент",
+    "HomeVideos": "Домашнє відео",
+    "FailedLoginAttemptWithUserName": "Невдала спроба входу від {0}",
+    "LabelRunningTimeValue": "Тривалість: {0}",
+    "TaskDownloadMissingSubtitlesDescription": "Шукає в Інтернеті відсутні субтитри на основі конфігурації метаданих.",
+    "TaskDownloadMissingSubtitles": "Завантажити відсутні субтитри",
+    "TaskRefreshChannelsDescription": "Оновлення інформації про Інтернет-канали.",
+    "TaskRefreshChannels": "Оновити канали",
+    "TaskCleanTranscodeDescription": "Вилучає файли для перекодування старше одного дня.",
+    "TaskCleanTranscode": "Очистити каталог перекодування",
+    "TaskUpdatePluginsDescription": "Завантажує та встановлює оновлення для плагінів, налаштованих на автоматичне оновлення.",
+    "TaskUpdatePlugins": "Оновити плагіни",
+    "TaskRefreshPeopleDescription": "Оновлення метаданих для акторів та режисерів у вашій медіатеці.",
+    "TaskRefreshPeople": "Оновити людей",
+    "TaskCleanLogsDescription": "Видаляє файли журналу, яким більше {0} днів.",
+    "TaskCleanLogs": "Очистити журнали",
+    "TaskRefreshLibraryDescription": "Сканує медіатеку на нові файли та оновлює метадані.",
+    "TaskRefreshLibrary": "Сканувати медіатеку",
+    "TaskRefreshChapterImagesDescription": "Створює ескізи для відео, які мають розділи.",
+    "TaskRefreshChapterImages": "Створити ескізи розділів",
+    "TaskCleanCacheDescription": "Видаляє файли кешу, які більше не потрібні системі.",
+    "TaskCleanCache": "Очистити кеш",
+    "TasksChannelsCategory": "Інтернет-канали",
+    "TasksApplicationCategory": "Додаток",
+    "TasksLibraryCategory": "Медіатека",
+    "TasksMaintenanceCategory": "Обслуговування",
+    "VersionNumber": "Версія {0}",
+    "ValueSpecialEpisodeName": "Спецепізод - {0}",
+    "ValueHasBeenAddedToLibrary": "{0} додано до медіатеки",
+    "UserStoppedPlayingItemWithValues": "{0} закінчив відтворення {1} на {2}",
+    "UserStartedPlayingItemWithValues": "{0} відтворює {1} на {2}",
+    "UserPolicyUpdatedWithName": "Політика користувача оновлена для {0}",
+    "UserPasswordChangedWithName": "Пароль змінено для користувача {0}",
+    "UserOnlineFromDevice": "{0} підключився з {1}",
+    "UserOfflineFromDevice": "{0} відключився від {1}",
+    "UserLockedOutWithName": "Користувача {0} заблоковано",
+    "UserDownloadingItemWithValues": "{0} завантажує {1}",
+    "UserDeletedWithName": "Користувача {0} видалено",
+    "UserCreatedWithName": "Користувача {0} створено",
+    "User": "Користувач",
+    "TvShows": "ТВ-шоу",
+    "System": "Система",
+    "Sync": "Синхронізація",
+    "SubtitleDownloadFailureFromForItem": "Не вдалося завантажити субтитри з {0} для {1}",
+    "StartupEmbyServerIsLoading": "Jellyfin Server завантажується. Будь ласка, спробуйте трішки пізніше.",
+    "Songs": "Пісні",
+    "Shows": "Шоу",
+    "ServerNameNeedsToBeRestarted": "{0} потрібно перезапустити",
+    "ScheduledTaskStartedWithName": "{0} розпочато",
+    "ScheduledTaskFailedWithName": "Помилка {0}",
+    "ProviderValue": "Постачальник: {0}",
+    "PluginUpdatedWithName": "{0} оновлено",
+    "PluginUninstalledWithName": "{0} видалено",
+    "PluginInstalledWithName": "{0} встановлено",
+    "Plugin": "Плагін",
+    "Playlists": "Плейлисти",
+    "Photos": "Фотографії",
+    "NotificationOptionVideoPlaybackStopped": "Відтворення відео зупинено",
+    "NotificationOptionVideoPlayback": "Розпочато відтворення відео",
+    "NotificationOptionUserLockedOut": "Користувача заблоковано",
+    "NotificationOptionTaskFailed": "Помилка запланованого завдання",
+    "NotificationOptionInstallationFailed": "Помилка встановлення",
+    "NotificationOptionCameraImageUploaded": "Фотографію завантажено",
+    "NotificationOptionAudioPlaybackStopped": "Відтворення аудіо зупинено",
+    "NotificationOptionAudioPlayback": "Розпочато відтворення аудіо",
+    "NotificationOptionApplicationUpdateInstalled": "Встановлено оновлення додатка",
+    "NotificationOptionApplicationUpdateAvailable": "Доступне оновлення додатка",
+    "NewVersionIsAvailable": "Для завантаження доступна нова версія Jellyfin Server.",
+    "NameSeasonUnknown": "Сезон Невідомий",
+    "NameSeasonNumber": "Сезон {0}",
+    "NameInstallFailed": "Не вдалося встановити {0}",
+    "MixedContent": "Змішаний контент",
+    "MessageServerConfigurationUpdated": "Конфігурація сервера оновлена",
+    "MessageNamedServerConfigurationUpdatedWithValue": "Розділ конфігурації сервера {0} оновлено",
+    "Inherit": "Успадкувати",
+    "HeaderRecordingGroups": "Групи запису"
 }

+ 1 - 1
Emby.Server.Implementations/Localization/Core/zh-TW.json

@@ -92,7 +92,7 @@
     "HeaderRecordingGroups": "錄製組",
     "Inherit": "繼承",
     "SubtitleDownloadFailureFromForItem": "無法為 {1} 從 {0} 下載字幕",
-    "TaskDownloadMissingSubtitlesDescription": "在網路上透過描述資料搜尋遺失的字幕。",
+    "TaskDownloadMissingSubtitlesDescription": "在網路上透過中繼資料搜尋遺失的字幕。",
     "TaskDownloadMissingSubtitles": "下載遺失的字幕",
     "TaskRefreshChannels": "重新整理頻道",
     "TaskUpdatePlugins": "更新插件",

+ 23 - 20
Emby.Server.Implementations/Net/UdpSocket.cs

@@ -15,13 +15,11 @@ namespace Emby.Server.Implementations.Net
     public sealed class UdpSocket : ISocket, IDisposable
     {
         private Socket _socket;
-        private int _localPort;
+        private readonly int _localPort;
         private bool _disposed = false;
 
         public Socket Socket => _socket;
 
-        public IPAddress LocalIPAddress { get; }
-
         private readonly SocketAsyncEventArgs _receiveSocketAsyncEventArgs = new SocketAsyncEventArgs()
         {
             SocketFlags = SocketFlags.None
@@ -51,18 +49,33 @@ namespace Emby.Server.Implementations.Net
             InitReceiveSocketAsyncEventArgs();
         }
 
+        public UdpSocket(Socket socket, IPEndPoint endPoint)
+        {
+            if (socket == null)
+            {
+                throw new ArgumentNullException(nameof(socket));
+            }
+
+            _socket = socket;
+            _socket.Connect(endPoint);
+
+            InitReceiveSocketAsyncEventArgs();
+        }
+
+        public IPAddress LocalIPAddress { get; }
+
         private void InitReceiveSocketAsyncEventArgs()
         {
             var receiveBuffer = new byte[8192];
             _receiveSocketAsyncEventArgs.SetBuffer(receiveBuffer, 0, receiveBuffer.Length);
-            _receiveSocketAsyncEventArgs.Completed += _receiveSocketAsyncEventArgs_Completed;
+            _receiveSocketAsyncEventArgs.Completed += OnReceiveSocketAsyncEventArgsCompleted;
 
             var sendBuffer = new byte[8192];
             _sendSocketAsyncEventArgs.SetBuffer(sendBuffer, 0, sendBuffer.Length);
-            _sendSocketAsyncEventArgs.Completed += _sendSocketAsyncEventArgs_Completed;
+            _sendSocketAsyncEventArgs.Completed += OnSendSocketAsyncEventArgsCompleted;
         }
 
-        private void _receiveSocketAsyncEventArgs_Completed(object sender, SocketAsyncEventArgs e)
+        private void OnReceiveSocketAsyncEventArgsCompleted(object sender, SocketAsyncEventArgs e)
         {
             var tcs = _currentReceiveTaskCompletionSource;
             if (tcs != null)
@@ -86,7 +99,7 @@ namespace Emby.Server.Implementations.Net
             }
         }
 
-        private void _sendSocketAsyncEventArgs_Completed(object sender, SocketAsyncEventArgs e)
+        private void OnSendSocketAsyncEventArgsCompleted(object sender, SocketAsyncEventArgs e)
         {
             var tcs = _currentSendTaskCompletionSource;
             if (tcs != null)
@@ -104,19 +117,6 @@ namespace Emby.Server.Implementations.Net
             }
         }
 
-        public UdpSocket(Socket socket, IPEndPoint endPoint)
-        {
-            if (socket == null)
-            {
-                throw new ArgumentNullException(nameof(socket));
-            }
-
-            _socket = socket;
-            _socket.Connect(endPoint);
-
-            InitReceiveSocketAsyncEventArgs();
-        }
-
         public IAsyncResult BeginReceive(byte[] buffer, int offset, int count, AsyncCallback callback)
         {
             ThrowIfDisposed();
@@ -247,6 +247,7 @@ namespace Emby.Server.Implementations.Net
             }
         }
 
+        /// <inheritdoc />
         public void Dispose()
         {
             if (_disposed)
@@ -255,6 +256,8 @@ namespace Emby.Server.Implementations.Net
             }
 
             _socket?.Dispose();
+            _receiveSocketAsyncEventArgs.Dispose();
+            _sendSocketAsyncEventArgs.Dispose();
             _currentReceiveTaskCompletionSource?.TrySetCanceled();
             _currentSendTaskCompletionSource?.TrySetCanceled();
 

+ 1 - 1
Emby.Server.Implementations/Networking/NetworkManager.cs

@@ -165,7 +165,7 @@ namespace Emby.Server.Implementations.Networking
                 (octet[0] == 127) || // RFC1122
                 (octet[0] == 169 && octet[1] == 254)) // RFC3927
             {
-                return false;
+                return true;
             }
 
             if (checkSubnets && IsInPrivateAddressSpaceAndLocalSubnet(endpoint))

+ 20 - 44
Emby.Server.Implementations/Playlists/PlaylistManager.cs

@@ -349,16 +349,14 @@ namespace Emby.Server.Implementations.Playlists
                         AlbumTitle = child.Album
                     };
 
-                    var hasAlbumArtist = child as IHasAlbumArtist;
-                    if (hasAlbumArtist != null)
+                    if (child is IHasAlbumArtist hasAlbumArtist)
                     {
-                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault();
+                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null;
                     }
 
-                    var hasArtist = child as IHasArtist;
-                    if (hasArtist != null)
+                    if (child is IHasArtist hasArtist)
                     {
-                        entry.TrackArtist = hasArtist.Artists.FirstOrDefault();
+                        entry.TrackArtist = hasArtist.Artists.Count > 0 ? hasArtist.Artists[0] : null;
                     }
 
                     if (child.RunTimeTicks.HasValue)
@@ -385,16 +383,14 @@ namespace Emby.Server.Implementations.Playlists
                         AlbumTitle = child.Album
                     };
 
-                    var hasAlbumArtist = child as IHasAlbumArtist;
-                    if (hasAlbumArtist != null)
+                    if (child is IHasAlbumArtist hasAlbumArtist)
                     {
-                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault();
+                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null;
                     }
 
-                    var hasArtist = child as IHasArtist;
-                    if (hasArtist != null)
+                    if (child is IHasArtist hasArtist)
                     {
-                        entry.TrackArtist = hasArtist.Artists.FirstOrDefault();
+                        entry.TrackArtist = hasArtist.Artists.Count > 0 ? hasArtist.Artists[0] : null;
                     }
 
                     if (child.RunTimeTicks.HasValue)
@@ -411,8 +407,10 @@ namespace Emby.Server.Implementations.Playlists
 
             if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase))
             {
-                var playlist = new M3uPlaylist();
-                playlist.IsExtended = true;
+                var playlist = new M3uPlaylist
+                {
+                    IsExtended = true
+                };
                 foreach (var child in item.GetLinkedChildren())
                 {
                     var entry = new M3uPlaylistEntry()
@@ -422,10 +420,9 @@ namespace Emby.Server.Implementations.Playlists
                         Album = child.Album
                     };
 
-                    var hasAlbumArtist = child as IHasAlbumArtist;
-                    if (hasAlbumArtist != null)
+                    if (child is IHasAlbumArtist hasAlbumArtist)
                     {
-                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault();
+                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null;
                     }
 
                     if (child.RunTimeTicks.HasValue)
@@ -453,10 +450,9 @@ namespace Emby.Server.Implementations.Playlists
                         Album = child.Album
                     };
 
-                    var hasAlbumArtist = child as IHasAlbumArtist;
-                    if (hasAlbumArtist != null)
+                    if (child is IHasAlbumArtist hasAlbumArtist)
                     {
-                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault();
+                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null;
                     }
 
                     if (child.RunTimeTicks.HasValue)
@@ -514,7 +510,7 @@ namespace Emby.Server.Implementations.Playlists
 
             if (!folderPath.EndsWith(Path.DirectorySeparatorChar))
             {
-                folderPath = folderPath + Path.DirectorySeparatorChar;
+                folderPath += Path.DirectorySeparatorChar;
             }
 
             var folderUri = new Uri(folderPath);
@@ -537,32 +533,12 @@ namespace Emby.Server.Implementations.Playlists
             return relativePath;
         }
 
-        private static string UnEscape(string content)
-        {
-            if (content == null)
-            {
-                return content;
-            }
-
-            return content.Replace("&amp;", "&").Replace("&apos;", "'").Replace("&quot;", "\"").Replace("&gt;", ">").Replace("&lt;", "<");
-        }
-
-        private static string Escape(string content)
-        {
-            if (content == null)
-            {
-                return null;
-            }
-
-            return content.Replace("&", "&amp;").Replace("'", "&apos;").Replace("\"", "&quot;").Replace(">", "&gt;").Replace("<", "&lt;");
-        }
-
         public Folder GetPlaylistsFolder(Guid userId)
         {
-            var typeName = "PlaylistsFolder";
+            const string TypeName = "PlaylistsFolder";
 
-            return _libraryManager.RootFolder.Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, typeName, StringComparison.Ordinal)) ??
-                _libraryManager.GetUserRootFolder().Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, typeName, StringComparison.Ordinal));
+            return _libraryManager.RootFolder.Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal)) ??
+                _libraryManager.GetUserRootFolder().Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal));
         }
     }
 }

+ 14 - 4
Emby.Server.Implementations/Services/ServiceController.cs

@@ -2,6 +2,7 @@
 
 using System;
 using System.Collections.Generic;
+using System.Globalization;
 using System.Threading.Tasks;
 using Emby.Server.Implementations.HttpServer;
 using MediaBrowser.Model.Services;
@@ -91,12 +92,22 @@ namespace Emby.Server.Implementations.Services
         {
             if (restPath.Path[0] != '/')
             {
-                throw new ArgumentException(string.Format("Route '{0}' on '{1}' must start with a '/'", restPath.Path, restPath.RequestType.GetMethodName()));
+                throw new ArgumentException(
+                    string.Format(
+                        CultureInfo.InvariantCulture,
+                        "Route '{0}' on '{1}' must start with a '/'",
+                        restPath.Path,
+                        restPath.RequestType.GetMethodName()));
             }
 
             if (restPath.Path.IndexOfAny(InvalidRouteChars) != -1)
             {
-                throw new ArgumentException(string.Format("Route '{0}' on '{1}' contains invalid chars. ", restPath.Path, restPath.RequestType.GetMethodName()));
+                throw new ArgumentException(
+                    string.Format(
+                        CultureInfo.InvariantCulture,
+                        "Route '{0}' on '{1}' contains invalid chars. ",
+                        restPath.Path,
+                        restPath.RequestType.GetMethodName()));
             }
 
             if (RestPathMap.TryGetValue(restPath.FirstMatchHashKey, out List<RestPath> pathsAtFirstMatch))
@@ -179,8 +190,7 @@ namespace Emby.Server.Implementations.Services
 
             var service = httpHost.CreateInstance(serviceType);
 
-            var serviceRequiresContext = service as IRequiresRequest;
-            if (serviceRequiresContext != null)
+            if (service is IRequiresRequest serviceRequiresContext)
             {
                 serviceRequiresContext.Request = req;
             }

+ 4 - 4
Emby.Server.Implementations/Services/ServiceHandler.cs

@@ -71,7 +71,7 @@ namespace Emby.Server.Implementations.Services
             return null;
         }
 
-        public async Task ProcessRequestAsync(HttpListenerHost httpHost, IRequest httpReq, HttpResponse httpRes, ILogger logger, CancellationToken cancellationToken)
+        public async Task ProcessRequestAsync(HttpListenerHost httpHost, IRequest httpReq, HttpResponse httpRes, CancellationToken cancellationToken)
         {
             httpReq.Items["__route"] = _restPath;
 
@@ -80,10 +80,10 @@ namespace Emby.Server.Implementations.Services
                 httpReq.ResponseContentType = _responseContentType;
             }
 
-            var request = await CreateRequest(httpHost, httpReq, _restPath, logger).ConfigureAwait(false);
+            var request = await CreateRequest(httpHost, httpReq, _restPath).ConfigureAwait(false);
 
             httpHost.ApplyRequestFilters(httpReq, httpRes, request);
-            
+
             httpRes.HttpContext.SetServiceStackRequest(httpReq);
             var response = await httpHost.ServiceController.Execute(httpHost, request, httpReq).ConfigureAwait(false);
 
@@ -96,7 +96,7 @@ namespace Emby.Server.Implementations.Services
             await ResponseHelper.WriteToResponse(httpRes, httpReq, response, cancellationToken).ConfigureAwait(false);
         }
 
-        public static async Task<object> CreateRequest(HttpListenerHost host, IRequest httpReq, RestPath restPath, ILogger logger)
+        public static async Task<object> CreateRequest(HttpListenerHost host, IRequest httpReq, RestPath restPath)
         {
             var requestType = restPath.RequestType;
 

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

+ 2 - 2
Emby.Server.Implementations/Session/SessionManager.cs

@@ -848,8 +848,8 @@ namespace Emby.Server.Implementations.Session
         /// </summary>
         /// <param name="info">The info.</param>
         /// <returns>Task.</returns>
-        /// <exception cref="ArgumentNullException">info</exception>
-        /// <exception cref="ArgumentOutOfRangeException">positionTicks</exception>
+        /// <exception cref="ArgumentNullException"><c>info</c> is <c>null</c>.</exception>
+        /// <exception cref="ArgumentOutOfRangeException"><c>info.PositionTicks</c> is <c>null</c> or negative.</exception>
         public async Task OnPlaybackStopped(PlaybackStopInfo info)
         {
             CheckDisposed();

+ 16 - 14
Emby.Server.Implementations/Session/SessionWebSocketListener.cs

@@ -93,7 +93,7 @@ namespace Emby.Server.Implementations.Session
             if (session != null)
             {
                 EnsureController(session, e.Argument);
-                await KeepAliveWebSocket(e.Argument);
+                await KeepAliveWebSocket(e.Argument).ConfigureAwait(false);
             }
             else
             {
@@ -177,7 +177,7 @@ namespace Emby.Server.Implementations.Session
             // Notify WebSocket about timeout
             try
             {
-                await SendForceKeepAlive(webSocket);
+                await SendForceKeepAlive(webSocket).ConfigureAwait(false);
             }
             catch (WebSocketException exception)
             {
@@ -233,6 +233,7 @@ namespace Emby.Server.Implementations.Session
                 if (_keepAliveCancellationToken != null)
                 {
                     _keepAliveCancellationToken.Cancel();
+                    _keepAliveCancellationToken.Dispose();
                     _keepAliveCancellationToken = null;
                 }
             }
@@ -268,7 +269,7 @@ namespace Emby.Server.Implementations.Session
                 lost = _webSockets.Where(i => (DateTime.UtcNow - i.LastKeepAliveDate).TotalSeconds >= WebSocketLostTimeout).ToList();
             }
 
-            if (inactive.Any())
+            if (inactive.Count > 0)
             {
                 _logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
             }
@@ -277,7 +278,7 @@ namespace Emby.Server.Implementations.Session
             {
                 try
                 {
-                    await SendForceKeepAlive(webSocket);
+                    await SendForceKeepAlive(webSocket).ConfigureAwait(false);
                 }
                 catch (WebSocketException exception)
                 {
@@ -288,7 +289,7 @@ namespace Emby.Server.Implementations.Session
 
             lock (_webSocketsLock)
             {
-                if (lost.Any())
+                if (lost.Count > 0)
                 {
                     _logger.LogInformation("Lost {0} WebSockets.", lost.Count);
                     foreach (var webSocket in lost)
@@ -298,7 +299,7 @@ namespace Emby.Server.Implementations.Session
                     }
                 }
 
-                if (!_webSockets.Any())
+                if (_webSockets.Count == 0)
                 {
                     StopKeepAlive();
                 }
@@ -312,11 +313,13 @@ namespace Emby.Server.Implementations.Session
         /// <returns>Task.</returns>
         private Task SendForceKeepAlive(IWebSocketConnection webSocket)
         {
-            return webSocket.SendAsync(new WebSocketMessage<int>
-            {
-                MessageType = "ForceKeepAlive",
-                Data = WebSocketLostTimeout
-            }, CancellationToken.None);
+            return webSocket.SendAsync(
+                new WebSocketMessage<int>
+                {
+                    MessageType = "ForceKeepAlive",
+                    Data = WebSocketLostTimeout
+                },
+                CancellationToken.None);
         }
 
         /// <summary>
@@ -330,12 +333,11 @@ namespace Emby.Server.Implementations.Session
         {
             while (!cancellationToken.IsCancellationRequested)
             {
-                await callback();
-                Task task = Task.Delay(interval, cancellationToken);
+                await callback().ConfigureAwait(false);
 
                 try
                 {
-                    await task;
+                    await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
                 }
                 catch (TaskCanceledException)
                 {

+ 2 - 2
Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs

@@ -154,8 +154,8 @@ namespace Emby.Server.Implementations.Sorting
 
         private static int CompareEpisodes(Episode x, Episode y)
         {
-            var xValue = (x.ParentIndexNumber ?? -1) * 1000 + (x.IndexNumber ?? -1);
-            var yValue = (y.ParentIndexNumber ?? -1) * 1000 + (y.IndexNumber ?? -1);
+            var xValue = ((x.ParentIndexNumber ?? -1) * 1000) + (x.IndexNumber ?? -1);
+            var yValue = ((y.ParentIndexNumber ?? -1) * 1000) + (y.IndexNumber ?? -1);
 
             return xValue.CompareTo(yValue);
         }

+ 32 - 28
Emby.Server.Implementations/SyncPlay/SyncPlayController.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Globalization;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
@@ -27,14 +28,17 @@ namespace Emby.Server.Implementations.SyncPlay
             /// All sessions will receive the message.
             /// </summary>
             AllGroup = 0,
+
             /// <summary>
             /// Only the specified session will receive the message.
             /// </summary>
             CurrentSession = 1,
+
             /// <summary>
             /// All sessions, except the current one, will receive the message.
             /// </summary>
             AllExceptCurrentSession = 2,
+
             /// <summary>
             /// Only sessions that are not buffering will receive the message.
             /// </summary>
@@ -56,15 +60,6 @@ namespace Emby.Server.Implementations.SyncPlay
         /// </summary>
         private readonly GroupInfo _group = new GroupInfo();
 
-        /// <inheritdoc />
-        public Guid GetGroupId() => _group.GroupId;
-
-        /// <inheritdoc />
-        public Guid GetPlayingItemId() => _group.PlayingItem.Id;
-
-        /// <inheritdoc />
-        public bool IsGroupEmpty() => _group.IsEmpty();
-
         /// <summary>
         /// Initializes a new instance of the <see cref="SyncPlayController" /> class.
         /// </summary>
@@ -78,6 +73,15 @@ namespace Emby.Server.Implementations.SyncPlay
             _syncPlayManager = syncPlayManager;
         }
 
+        /// <inheritdoc />
+        public Guid GetGroupId() => _group.GroupId;
+
+        /// <inheritdoc />
+        public Guid GetPlayingItemId() => _group.PlayingItem.Id;
+
+        /// <inheritdoc />
+        public bool IsGroupEmpty() => _group.IsEmpty();
+
         /// <summary>
         /// Converts DateTime to UTC string.
         /// </summary>
@@ -85,7 +89,7 @@ namespace Emby.Server.Implementations.SyncPlay
         /// <value>The UTC string.</value>
         private string DateToUTCString(DateTime date)
         {
-            return date.ToUniversalTime().ToString("o");
+            return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture);
         }
 
         /// <summary>
@@ -94,23 +98,23 @@ namespace Emby.Server.Implementations.SyncPlay
         /// <param name="from">The current session.</param>
         /// <param name="type">The filtering type.</param>
         /// <value>The array of sessions matching the filter.</value>
-        private SessionInfo[] FilterSessions(SessionInfo from, BroadcastType type)
+        private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, BroadcastType type)
         {
             switch (type)
             {
                 case BroadcastType.CurrentSession:
                     return new SessionInfo[] { from };
                 case BroadcastType.AllGroup:
-                    return _group.Participants.Values.Select(
-                        session => session.Session).ToArray();
+                    return _group.Participants.Values
+                        .Select(session => session.Session);
                 case BroadcastType.AllExceptCurrentSession:
-                    return _group.Participants.Values.Select(
-                        session => session.Session).Where(
-                        session => !session.Id.Equals(from.Id)).ToArray();
+                    return _group.Participants.Values
+                        .Select(session => session.Session)
+                        .Where(session => !session.Id.Equals(from.Id, StringComparison.Ordinal));
                 case BroadcastType.AllReady:
-                    return _group.Participants.Values.Where(
-                        session => !session.IsBuffering).Select(
-                        session => session.Session).ToArray();
+                    return _group.Participants.Values
+                        .Where(session => !session.IsBuffering)
+                        .Select(session => session.Session);
                 default:
                     return Array.Empty<SessionInfo>();
             }
@@ -128,10 +132,9 @@ namespace Emby.Server.Implementations.SyncPlay
         {
             IEnumerable<Task> GetTasks()
             {
-                SessionInfo[] sessions = FilterSessions(from, type);
-                foreach (var session in sessions)
+                foreach (var session in FilterSessions(from, type))
                 {
-                    yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), message, cancellationToken);
+                    yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken);
                 }
             }
 
@@ -150,10 +153,9 @@ namespace Emby.Server.Implementations.SyncPlay
         {
             IEnumerable<Task> GetTasks()
             {
-                SessionInfo[] sessions = FilterSessions(from, type);
-                foreach (var session in sessions)
+                foreach (var session in FilterSessions(from, type))
                 {
-                    yield return _sessionManager.SendSyncPlayCommand(session.Id.ToString(), message, cancellationToken);
+                    yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken);
                 }
             }
 
@@ -236,9 +238,11 @@ namespace Emby.Server.Implementations.SyncPlay
             }
             else
             {
-                var playRequest = new PlayRequest();
-                playRequest.ItemIds = new Guid[] { _group.PlayingItem.Id };
-                playRequest.StartPositionTicks = _group.PositionTicks;
+                var playRequest = new PlayRequest
+                {
+                    ItemIds = new Guid[] { _group.PlayingItem.Id },
+                    StartPositionTicks = _group.PositionTicks
+                };
                 var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest);
                 SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken);
             }

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

+ 56 - 0
Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.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.FirstTimeOrIgnoreParentalControlSetupPolicy
+{
+    /// <summary>
+    /// Ignore parental control schedule and allow before startup wizard has been completed.
+    /// </summary>
+    public class FirstTimeOrIgnoreParentalControlSetupHandler : BaseAuthorizationHandler<FirstTimeOrIgnoreParentalControlSetupRequirement>
+    {
+        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, FirstTimeOrIgnoreParentalControlSetupRequirement 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();
+        }
+    }
+}

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

@@ -0,0 +1,273 @@
+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.Http.Extensions;
+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;
+
+            var requestPathAndQuery = Request.GetEncodedPathAndQuery();
+            // Bounce them to the startup wizard if it hasn't been completed yet
+            if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted
+                && !requestPathAndQuery.Contains("wizard", StringComparison.OrdinalIgnoreCase)
+                && requestPathAndQuery.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);
+        }
+    }
+}

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

@@ -0,0 +1,2221 @@
+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);
+
+            if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs))
+            {
+                codecs.Append(',');
+            }
+
+            codecs.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());
+        }
+    }
+}

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