ソースを参照

Merge remote-tracking branch 'upstream/master' into query-fields

crobibero 4 年 前
コミット
6748ba287d
91 ファイル変更1358 行追加974 行削除
  1. 3 0
      .ci/azure-pipelines-abi.yml
  2. 3 2
      .ci/azure-pipelines-package.yml
  3. 2 2
      Emby.Dlna/ContentDirectory/ControlHandler.cs
  4. 1 1
      Emby.Dlna/Didl/DidlBuilder.cs
  5. 3 3
      Emby.Dlna/DlnaManager.cs
  6. 1 1
      Emby.Dlna/Eventing/DlnaEventManager.cs
  7. 2 2
      Emby.Dlna/Main/DlnaEntryPoint.cs
  8. 2 2
      Emby.Dlna/PlayTo/PlayToController.cs
  9. 1 0
      Emby.Dlna/Service/BaseControlHandler.cs
  10. 5 0
      Emby.Naming/Video/CleanDateTimeParser.cs
  11. 4 1
      Emby.Notifications/NotificationEntryPoint.cs
  12. 30 54
      Emby.Server.Implementations/ApplicationHost.cs
  13. 6 11
      Emby.Server.Implementations/Channels/ChannelManager.cs
  14. 32 31
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  15. 4 3
      Emby.Server.Implementations/HttpServer/Security/AuthService.cs
  16. 75 68
      Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
  17. 15 0
      Emby.Server.Implementations/Library/LibraryManager.cs
  18. 8 2
      Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
  19. 5 0
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs
  20. 4 2
      Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs
  21. 18 8
      Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
  22. 7 1
      Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs
  23. 3 1
      Emby.Server.Implementations/Localization/Core/cs.json
  24. 3 1
      Emby.Server.Implementations/Localization/Core/de.json
  25. 1 1
      Emby.Server.Implementations/Localization/Core/es.json
  26. 3 1
      Emby.Server.Implementations/Localization/Core/fr.json
  27. 61 59
      Emby.Server.Implementations/Localization/Core/hr.json
  28. 3 1
      Emby.Server.Implementations/Localization/Core/it.json
  29. 4 2
      Emby.Server.Implementations/Localization/Core/ja.json
  30. 4 2
      Emby.Server.Implementations/Localization/Core/ko.json
  31. 3 1
      Emby.Server.Implementations/Localization/Core/ru.json
  32. 21 21
      Emby.Server.Implementations/Localization/Core/sl-SI.json
  33. 5 3
      Emby.Server.Implementations/Localization/Core/tr.json
  34. 3 1
      Emby.Server.Implementations/Localization/Core/vi.json
  35. 3 1
      Emby.Server.Implementations/Localization/Core/zh-CN.json
  36. 3 1
      Emby.Server.Implementations/Localization/Core/zh-TW.json
  37. 5 5
      Emby.Server.Implementations/Updates/InstallationManager.cs
  38. 7 0
      Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
  39. 7 8
      Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
  40. 5 3
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
  41. 5 3
      Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs
  42. 3 3
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
  43. 5 0
      Jellyfin.Api/Constants/InternalClaimTypes.cs
  44. 6 6
      Jellyfin.Api/Controllers/ArtistsController.cs
  45. 1 5
      Jellyfin.Api/Controllers/DevicesController.cs
  46. 3 0
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  47. 26 0
      Jellyfin.Api/Controllers/DlnaServerController.cs
  48. 9 3
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  49. 12 129
      Jellyfin.Api/Controllers/GenresController.cs
  50. 2 2
      Jellyfin.Api/Controllers/InstantMixController.cs
  51. 4 7
      Jellyfin.Api/Controllers/LiveTvController.cs
  52. 2 2
      Jellyfin.Api/Controllers/MediaInfoController.cs
  53. 6 132
      Jellyfin.Api/Controllers/MusicGenresController.cs
  54. 23 162
      Jellyfin.Api/Controllers/PersonsController.cs
  55. 5 129
      Jellyfin.Api/Controllers/StudiosController.cs
  56. 125 0
      Jellyfin.Api/Controllers/SubtitleController.cs
  57. 3 0
      Jellyfin.Api/Controllers/SuggestionsController.cs
  58. 18 14
      Jellyfin.Api/Controllers/UniversalAudioController.cs
  59. 27 0
      Jellyfin.Api/Controllers/UserController.cs
  60. 13 0
      Jellyfin.Api/Helpers/ClaimHelpers.cs
  61. 2 3
      Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
  62. 2 2
      Jellyfin.Api/Helpers/MediaInfoHelper.cs
  63. 166 0
      Jellyfin.Api/Helpers/ProgressiveFileStream.cs
  64. 67 0
      Jellyfin.Api/Helpers/RequestHelpers.cs
  65. 2 2
      Jellyfin.Api/Helpers/SimilarItemsHelper.cs
  66. 1 1
      Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs
  67. 34 0
      Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs
  68. 16 1
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  69. 2 2
      MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs
  70. 113 0
      MediaBrowser.Common/Plugins/LocalPlugin.cs
  71. 5 0
      MediaBrowser.Controller/Entities/InternalPeopleQuery.cs
  72. 6 0
      MediaBrowser.Controller/IDisplayPreferencesManager.cs
  73. 14 5
      MediaBrowser.Controller/IServerApplicationHost.cs
  74. 2 0
      MediaBrowser.Controller/Library/ILibraryManager.cs
  75. 2 0
      MediaBrowser.Controller/Library/IMediaSourceManager.cs
  76. 4 3
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  77. 5 0
      MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
  78. 16 2
      MediaBrowser.Controller/Net/AuthorizationInfo.cs
  79. 9 0
      MediaBrowser.Controller/Subtitles/ISubtitleManager.cs
  80. 5 0
      MediaBrowser.Model/Configuration/EncodingOptions.cs
  81. 2 2
      MediaBrowser.Model/Dlna/AudioOptions.cs
  82. 2 2
      MediaBrowser.Model/Dlna/DeviceProfile.cs
  83. 1 1
      MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs
  84. 34 0
      MediaBrowser.Model/Subtitles/FontFile.cs
  85. 42 27
      MediaBrowser.Providers/Subtitles/SubtitleManager.cs
  86. 4 2
      tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs
  87. 1 1
      tests/Jellyfin.Api.Tests/TestHelpers.cs
  88. 13 13
      tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs
  89. 92 0
      tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedIReadOnlyListTests.cs
  90. 2 2
      tests/Jellyfin.Common.Tests/Models/GenericBodyArrayModel.cs
  91. 19 0
      tests/Jellyfin.Common.Tests/Models/GenericBodyIReadOnlyListModel.cs

+ 3 - 0
.ci/azure-pipelines-abi.yml

@@ -62,6 +62,7 @@ jobs:
 
 
       - task: DownloadPipelineArtifact@2
       - task: DownloadPipelineArtifact@2
         displayName: 'Download Reference Assembly Build Artifact'
         displayName: 'Download Reference Assembly Build Artifact'
+        enabled: false
         inputs:
         inputs:
           source: "specific"
           source: "specific"
           artifact: "$(NugetPackageName)"
           artifact: "$(NugetPackageName)"
@@ -73,6 +74,7 @@ jobs:
 
 
       - task: CopyFiles@2
       - task: CopyFiles@2
         displayName: 'Copy Reference Assembly Build Artifact'
         displayName: 'Copy Reference Assembly Build Artifact'
+        enabled: false
         inputs:
         inputs:
           sourceFolder: $(System.ArtifactsDirectory)/current-artifacts
           sourceFolder: $(System.ArtifactsDirectory)/current-artifacts
           contents: '**/*.dll'
           contents: '**/*.dll'
@@ -83,6 +85,7 @@ jobs:
 
 
       - task: DotNetCoreCLI@2
       - task: DotNetCoreCLI@2
         displayName: 'Execute ABI Compatibility Check Tool'
         displayName: 'Execute ABI Compatibility Check Tool'
+        enabled: false
         inputs:
         inputs:
           command: custom
           command: custom
           custom: compat
           custom: compat

+ 3 - 2
.ci/azure-pipelines-package.yml

@@ -63,6 +63,7 @@ jobs:
       sshEndpoint: repository
       sshEndpoint: repository
       sourceFolder: '$(Build.SourcesDirectory)/deployment/dist'
       sourceFolder: '$(Build.SourcesDirectory)/deployment/dist'
       contents: '**'
       contents: '**'
+      targetFolder: '/srv/repository/incoming/azure/$(Build.BuildNumber)/$(BuildConfiguration)'
 
 
 - job: OpenAPISpec
 - job: OpenAPISpec
   dependsOn: Test
   dependsOn: Test
@@ -166,7 +167,7 @@ jobs:
     inputs:
     inputs:
       sshEndpoint: repository
       sshEndpoint: repository
       runOptions: 'commands'
       runOptions: 'commands'
-      commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable &
+      commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable &
 
 
   - task: SSH@0
   - task: SSH@0
     displayName: 'Update Stable Repository'
     displayName: 'Update Stable Repository'
@@ -175,7 +176,7 @@ jobs:
     inputs:
     inputs:
       sshEndpoint: repository
       sshEndpoint: repository
       runOptions: 'commands'
       runOptions: 'commands'
-      commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) &
+      commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) &
 
 
 - job: PublishNuget
 - job: PublishNuget
   displayName: 'Publish NuGet packages'
   displayName: 'Publish NuGet packages'

+ 2 - 2
Emby.Dlna/ContentDirectory/ControlHandler.cs

@@ -1346,8 +1346,8 @@ namespace Emby.Dlna.ContentDirectory
             {
             {
                 if (id.StartsWith(name + "_", StringComparison.OrdinalIgnoreCase))
                 if (id.StartsWith(name + "_", StringComparison.OrdinalIgnoreCase))
                 {
                 {
-                    stubType = (StubType)Enum.Parse(typeof(StubType), name, true);
-                    id = id.Split(new[] { '_' }, 2)[1];
+                    stubType = Enum.Parse<StubType>(name, true);
+                    id = id.Split('_', 2)[1];
 
 
                     break;
                     break;
                 }
                 }

+ 1 - 1
Emby.Dlna/Didl/DidlBuilder.cs

@@ -123,7 +123,7 @@ namespace Emby.Dlna.Didl
         {
         {
             foreach (var att in profile.XmlRootAttributes)
             foreach (var att in profile.XmlRootAttributes)
             {
             {
-                var parts = att.Name.Split(new[] { ':' }, StringSplitOptions.RemoveEmptyEntries);
+                var parts = att.Name.Split(':', StringSplitOptions.RemoveEmptyEntries);
                 if (parts.Length == 2)
                 if (parts.Length == 2)
                 {
                 {
                     writer.WriteAttributeString(parts[0], parts[1], null, att.Value);
                     writer.WriteAttributeString(parts[0], parts[1], null, att.Value);

+ 3 - 3
Emby.Dlna/DlnaManager.cs

@@ -383,9 +383,9 @@ namespace Emby.Dlna
                     continue;
                     continue;
                 }
                 }
 
 
-                var filename = Path.GetFileName(name).Substring(namespaceName.Length);
-
-                var path = Path.Combine(systemProfilesPath, filename);
+                var path = Path.Join(
+                    systemProfilesPath,
+                    Path.GetFileName(name.AsSpan()).Slice(namespaceName.Length));
 
 
                 using (var stream = _assembly.GetManifestResourceStream(name))
                 using (var stream = _assembly.GetManifestResourceStream(name))
                 {
                 {

+ 1 - 1
Emby.Dlna/Eventing/DlnaEventManager.cs

@@ -168,7 +168,7 @@ namespace Emby.Dlna.Eventing
 
 
             builder.Append("</e:propertyset>");
             builder.Append("</e:propertyset>");
 
 
-            using var options = new HttpRequestMessage(new HttpMethod("NOTIFY"),  subscription.CallbackUrl);
+            using var options = new HttpRequestMessage(new HttpMethod("NOTIFY"), subscription.CallbackUrl);
             options.Content = new StringContent(builder.ToString(), Encoding.UTF8, MediaTypeNames.Text.Xml);
             options.Content = new StringContent(builder.ToString(), Encoding.UTF8, MediaTypeNames.Text.Xml);
             options.Headers.TryAddWithoutValidation("NT", subscription.NotificationType);
             options.Headers.TryAddWithoutValidation("NT", subscription.NotificationType);
             options.Headers.TryAddWithoutValidation("NTS", "upnp:propchange");
             options.Headers.TryAddWithoutValidation("NTS", "upnp:propchange");

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

@@ -257,9 +257,10 @@ namespace Emby.Dlna.Main
 
 
         private async Task RegisterServerEndpoints()
         private async Task RegisterServerEndpoints()
         {
         {
-            var addresses = await _appHost.GetLocalIpAddresses(CancellationToken.None).ConfigureAwait(false);
+            var addresses = await _appHost.GetLocalIpAddresses().ConfigureAwait(false);
 
 
             var udn = CreateUuid(_appHost.SystemId);
             var udn = CreateUuid(_appHost.SystemId);
+            var descriptorUri = "/dlna/" + udn + "/description.xml";
 
 
             foreach (var address in addresses)
             foreach (var address in addresses)
             {
             {
@@ -279,7 +280,6 @@ namespace Emby.Dlna.Main
 
 
                 _logger.LogInformation("Registering publisher for {0} on {1}", fullService, address);
                 _logger.LogInformation("Registering publisher for {0} on {1}", fullService, address);
 
 
-                var descriptorUri = "/dlna/" + udn + "/description.xml";
                 var uri = new Uri(_appHost.GetLocalApiUrl(address) + descriptorUri);
                 var uri = new Uri(_appHost.GetLocalApiUrl(address) + descriptorUri);
 
 
                 var device = new SsdpRootDevice
                 var device = new SsdpRootDevice

+ 2 - 2
Emby.Dlna/PlayTo/PlayToController.cs

@@ -326,7 +326,7 @@ namespace Emby.Dlna.PlayTo
 
 
         public Task SendPlayCommand(PlayRequest command, CancellationToken cancellationToken)
         public Task SendPlayCommand(PlayRequest command, CancellationToken cancellationToken)
         {
         {
-            _logger.LogDebug("{0} - Received PlayRequest: {1}", this._session.DeviceName, command.PlayCommand);
+            _logger.LogDebug("{0} - Received PlayRequest: {1}", _session.DeviceName, command.PlayCommand);
 
 
             var user = command.ControllingUserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(command.ControllingUserId);
             var user = command.ControllingUserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(command.ControllingUserId);
 
 
@@ -339,7 +339,7 @@ namespace Emby.Dlna.PlayTo
             var startIndex = command.StartIndex ?? 0;
             var startIndex = command.StartIndex ?? 0;
             if (startIndex > 0)
             if (startIndex > 0)
             {
             {
-                items = items.Skip(startIndex).ToList();
+                items = items.GetRange(startIndex, items.Count - startIndex);
             }
             }
 
 
             var playlist = new List<PlaylistItem>();
             var playlist = new List<PlaylistItem>();

+ 1 - 0
Emby.Dlna/Service/BaseControlHandler.cs

@@ -169,6 +169,7 @@ namespace Emby.Dlna.Service
                         var result = new ControlRequestInfo(localName, namespaceURI);
                         var result = new ControlRequestInfo(localName, namespaceURI);
                         using var subReader = reader.ReadSubtree();
                         using var subReader = reader.ReadSubtree();
                         await ParseFirstBodyChildAsync(subReader, result.Headers).ConfigureAwait(false);
                         await ParseFirstBodyChildAsync(subReader, result.Headers).ConfigureAwait(false);
+                        return result;
                     }
                     }
                     else
                     else
                     {
                     {

+ 5 - 0
Emby.Naming/Video/CleanDateTimeParser.cs

@@ -15,6 +15,11 @@ namespace Emby.Naming.Video
         public static CleanDateTimeResult Clean(string name, IReadOnlyList<Regex> cleanDateTimeRegexes)
         public static CleanDateTimeResult Clean(string name, IReadOnlyList<Regex> cleanDateTimeRegexes)
         {
         {
             CleanDateTimeResult result = new CleanDateTimeResult(name);
             CleanDateTimeResult result = new CleanDateTimeResult(name);
+            if (string.IsNullOrEmpty(name))
+            {
+                return result;
+            }
+
             var len = cleanDateTimeRegexes.Count;
             var len = cleanDateTimeRegexes.Count;
             for (int i = 0; i < len; i++)
             for (int i = 0; i < len; i++)
             {
             {

+ 4 - 1
Emby.Notifications/NotificationEntryPoint.cs

@@ -209,7 +209,10 @@ namespace Emby.Notifications
                 _libraryUpdateTimer = null;
                 _libraryUpdateTimer = null;
             }
             }
 
 
-            items = items.Take(10).ToList();
+            if (items.Count > 10)
+            {
+                items = items.GetRange(0, 10);
+            }
 
 
             foreach (var item in items)
             foreach (var item in items)
             {
             {

+ 30 - 54
Emby.Server.Implementations/ApplicationHost.cs

@@ -4,7 +4,6 @@ using System;
 using System.Collections.Concurrent;
 using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Diagnostics;
 using System.Diagnostics;
-using System.Globalization;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
 using System.Net;
 using System.Net;
@@ -30,7 +29,6 @@ using Emby.Server.Implementations.Cryptography;
 using Emby.Server.Implementations.Data;
 using Emby.Server.Implementations.Data;
 using Emby.Server.Implementations.Devices;
 using Emby.Server.Implementations.Devices;
 using Emby.Server.Implementations.Dto;
 using Emby.Server.Implementations.Dto;
-using Emby.Server.Implementations.HttpServer;
 using Emby.Server.Implementations.HttpServer.Security;
 using Emby.Server.Implementations.HttpServer.Security;
 using Emby.Server.Implementations.IO;
 using Emby.Server.Implementations.IO;
 using Emby.Server.Implementations.Library;
 using Emby.Server.Implementations.Library;
@@ -993,62 +991,36 @@ namespace Emby.Server.Implementations
 
 
         protected abstract void RestartInternal();
         protected abstract void RestartInternal();
 
 
-        /// <summary>
-        /// Comparison function used in <see cref="GetPlugins" />.
-        /// </summary>
-        /// <param name="a">Item to compare.</param>
-        /// <param name="b">Item to compare with.</param>
-        /// <returns>Boolean result of the operation.</returns>
-        private static int VersionCompare(
-            (Version PluginVersion, string Name, string Path) a,
-            (Version PluginVersion, string Name, string Path) b)
-        {
-            int compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture);
-
-            if (compare == 0)
-            {
-                return a.PluginVersion.CompareTo(b.PluginVersion);
-            }
-
-            return compare;
-        }
-
-        /// <summary>
-        /// Returns a list of plugins to install.
-        /// </summary>
-        /// <param name="path">Path to check.</param>
-        /// <param name="cleanup">True if an attempt should be made to delete old plugs.</param>
-        /// <returns>Enumerable list of dlls to load.</returns>
-        private IEnumerable<string> GetPlugins(string path, bool cleanup = true)
+        /// <inheritdoc/>
+        public IEnumerable<LocalPlugin> GetLocalPlugins(string path, bool cleanup = true)
         {
         {
-            var dllList = new List<string>();
-            var versions = new List<(Version PluginVersion, string Name, string Path)>();
+            var minimumVersion = new Version(0, 0, 0, 1);
+            var versions = new List<LocalPlugin>();
             var directories = Directory.EnumerateDirectories(path, "*.*", SearchOption.TopDirectoryOnly);
             var directories = Directory.EnumerateDirectories(path, "*.*", SearchOption.TopDirectoryOnly);
-            string metafile;
 
 
             foreach (var dir in directories)
             foreach (var dir in directories)
             {
             {
                 try
                 try
                 {
                 {
-                    metafile = Path.Combine(dir, "meta.json");
+                    var metafile = Path.Combine(dir, "meta.json");
                     if (File.Exists(metafile))
                     if (File.Exists(metafile))
                     {
                     {
                         var manifest = _jsonSerializer.DeserializeFromFile<PluginManifest>(metafile);
                         var manifest = _jsonSerializer.DeserializeFromFile<PluginManifest>(metafile);
 
 
                         if (!Version.TryParse(manifest.TargetAbi, out var targetAbi))
                         if (!Version.TryParse(manifest.TargetAbi, out var targetAbi))
                         {
                         {
-                            targetAbi = new Version(0, 0, 0, 1);
+                            targetAbi = minimumVersion;
                         }
                         }
 
 
                         if (!Version.TryParse(manifest.Version, out var version))
                         if (!Version.TryParse(manifest.Version, out var version))
                         {
                         {
-                            version = new Version(0, 0, 0, 1);
+                            version = minimumVersion;
                         }
                         }
 
 
                         if (ApplicationVersion >= targetAbi)
                         if (ApplicationVersion >= targetAbi)
                         {
                         {
                             // Only load Plugins if the plugin is built for this version or below.
                             // Only load Plugins if the plugin is built for this version or below.
-                            versions.Add((version, manifest.Name, dir));
+                            versions.Add(new LocalPlugin(manifest.Guid, manifest.Name, version, dir));
                         }
                         }
                     }
                     }
                     else
                     else
@@ -1057,15 +1029,15 @@ namespace Emby.Server.Implementations
                         metafile = dir.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries)[^1];
                         metafile = dir.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries)[^1];
 
 
                         int versionIndex = dir.LastIndexOf('_');
                         int versionIndex = dir.LastIndexOf('_');
-                        if (versionIndex != -1 && Version.TryParse(dir.Substring(versionIndex + 1), out Version ver))
+                        if (versionIndex != -1 && Version.TryParse(dir.Substring(versionIndex + 1), out Version parsedVersion))
                         {
                         {
                             // Versioned folder.
                             // Versioned folder.
-                            versions.Add((ver, metafile, dir));
+                            versions.Add(new LocalPlugin(Guid.Empty, metafile, parsedVersion, dir));
                         }
                         }
                         else
                         else
                         {
                         {
                             // Un-versioned folder - Add it under the path name and version 0.0.0.1.
                             // Un-versioned folder - Add it under the path name and version 0.0.0.1.
-                            versions.Add((new Version(0, 0, 0, 1), metafile, dir));
+                            versions.Add(new LocalPlugin(Guid.Empty, metafile, minimumVersion, dir));
                         }
                         }
                     }
                     }
                 }
                 }
@@ -1076,14 +1048,14 @@ namespace Emby.Server.Implementations
             }
             }
 
 
             string lastName = string.Empty;
             string lastName = string.Empty;
-            versions.Sort(VersionCompare);
+            versions.Sort(LocalPlugin.Compare);
             // Traverse backwards through the list.
             // Traverse backwards through the list.
             // The first item will be the latest version.
             // The first item will be the latest version.
             for (int x = versions.Count - 1; x >= 0; x--)
             for (int x = versions.Count - 1; x >= 0; x--)
             {
             {
                 if (!string.Equals(lastName, versions[x].Name, StringComparison.OrdinalIgnoreCase))
                 if (!string.Equals(lastName, versions[x].Name, StringComparison.OrdinalIgnoreCase))
                 {
                 {
-                    dllList.AddRange(Directory.EnumerateFiles(versions[x].Path, "*.dll", SearchOption.AllDirectories));
+                    versions[x].DllFiles.AddRange(Directory.EnumerateFiles(versions[x].Path, "*.dll", SearchOption.AllDirectories));
                     lastName = versions[x].Name;
                     lastName = versions[x].Name;
                     continue;
                     continue;
                 }
                 }
@@ -1091,6 +1063,7 @@ namespace Emby.Server.Implementations
                 if (!string.IsNullOrEmpty(lastName) && cleanup)
                 if (!string.IsNullOrEmpty(lastName) && cleanup)
                 {
                 {
                     // Attempt a cleanup of old folders.
                     // Attempt a cleanup of old folders.
+                    versions.RemoveAt(x);
                     try
                     try
                     {
                     {
                         Logger.LogDebug("Deleting {Path}", versions[x].Path);
                         Logger.LogDebug("Deleting {Path}", versions[x].Path);
@@ -1103,7 +1076,7 @@ namespace Emby.Server.Implementations
                 }
                 }
             }
             }
 
 
-            return dllList;
+            return versions;
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -1114,21 +1087,24 @@ namespace Emby.Server.Implementations
         {
         {
             if (Directory.Exists(ApplicationPaths.PluginsPath))
             if (Directory.Exists(ApplicationPaths.PluginsPath))
             {
             {
-                foreach (var file in GetPlugins(ApplicationPaths.PluginsPath))
+                foreach (var plugin in GetLocalPlugins(ApplicationPaths.PluginsPath))
                 {
                 {
-                    Assembly plugAss;
-                    try
-                    {
-                        plugAss = Assembly.LoadFrom(file);
-                    }
-                    catch (FileLoadException ex)
+                    foreach (var file in plugin.DllFiles)
                     {
                     {
-                        Logger.LogError(ex, "Failed to load assembly {Path}", file);
-                        continue;
-                    }
+                        Assembly plugAss;
+                        try
+                        {
+                            plugAss = Assembly.LoadFrom(file);
+                        }
+                        catch (FileLoadException ex)
+                        {
+                            Logger.LogError(ex, "Failed to load assembly {Path}", file);
+                            continue;
+                        }
 
 
-                    Logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugAss.FullName, file);
-                    yield return plugAss;
+                        Logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugAss.FullName, file);
+                        yield return plugAss;
+                    }
                 }
                 }
             }
             }
 
 

+ 6 - 11
Emby.Server.Implementations/Channels/ChannelManager.cs

@@ -250,21 +250,16 @@ namespace Emby.Server.Implementations.Channels
             var all = channels;
             var all = channels;
             var totalCount = all.Count;
             var totalCount = all.Count;
 
 
-            if (query.StartIndex.HasValue)
+            if (query.StartIndex.HasValue || query.Limit.HasValue)
             {
             {
-                all = all.Skip(query.StartIndex.Value).ToList();
+                int startIndex = query.StartIndex ?? 0;
+                int count = query.Limit == null ? totalCount - startIndex : Math.Min(query.Limit.Value, totalCount - startIndex);
+                all = all.GetRange(startIndex, count);
             }
             }
 
 
-            if (query.Limit.HasValue)
-            {
-                all = all.Take(query.Limit.Value).ToList();
-            }
-
-            var returnItems = all.ToArray();
-
             if (query.RefreshLatestChannelItems)
             if (query.RefreshLatestChannelItems)
             {
             {
-                foreach (var item in returnItems)
+                foreach (var item in all)
                 {
                 {
                     RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).GetAwaiter().GetResult();
                     RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).GetAwaiter().GetResult();
                 }
                 }
@@ -272,7 +267,7 @@ namespace Emby.Server.Implementations.Channels
 
 
             return new QueryResult<Channel>
             return new QueryResult<Channel>
             {
             {
-                Items = returnItems,
+                Items = all,
                 TotalRecordCount = totalCount
                 TotalRecordCount = totalCount
             };
             };
         }
         }

+ 32 - 31
Emby.Server.Implementations/Data/SqliteItemRepository.cs

@@ -5002,26 +5002,33 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
 
 
             CheckDisposed();
             CheckDisposed();
 
 
-            var commandText = "select Distinct Name from People";
+            var commandText = new StringBuilder("select Distinct p.Name from People p");
+
+            if (query.User != null && query.IsFavorite.HasValue)
+            {
+                commandText.Append(" LEFT JOIN TypedBaseItems tbi ON tbi.Name=p.Name AND tbi.Type='");
+                commandText.Append(typeof(Person).FullName);
+                commandText.Append("' LEFT JOIN UserDatas ON tbi.UserDataKey=key AND userId=@UserId");
+            }
 
 
             var whereClauses = GetPeopleWhereClauses(query, null);
             var whereClauses = GetPeopleWhereClauses(query, null);
 
 
             if (whereClauses.Count != 0)
             if (whereClauses.Count != 0)
             {
             {
-                commandText += "  where " + string.Join(" AND ", whereClauses);
+                commandText.Append(" where ").Append(string.Join(" AND ", whereClauses));
             }
             }
 
 
-            commandText += " order by ListOrder";
+            commandText.Append(" order by ListOrder");
 
 
             if (query.Limit > 0)
             if (query.Limit > 0)
             {
             {
-                commandText += " LIMIT " + query.Limit;
+                commandText.Append(" LIMIT ").Append(query.Limit);
             }
             }
 
 
             using (var connection = GetConnection(true))
             using (var connection = GetConnection(true))
             {
             {
                 var list = new List<string>();
                 var list = new List<string>();
-                using (var statement = PrepareStatement(connection, commandText))
+                using (var statement = PrepareStatement(connection, commandText.ToString()))
                 {
                 {
                     // Run this again to bind the params
                     // Run this again to bind the params
                     GetPeopleWhereClauses(query, statement);
                     GetPeopleWhereClauses(query, statement);
@@ -5087,19 +5094,13 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             if (!query.ItemId.Equals(Guid.Empty))
             if (!query.ItemId.Equals(Guid.Empty))
             {
             {
                 whereClauses.Add("ItemId=@ItemId");
                 whereClauses.Add("ItemId=@ItemId");
-                if (statement != null)
-                {
-                    statement.TryBind("@ItemId", query.ItemId.ToByteArray());
-                }
+                statement?.TryBind("@ItemId", query.ItemId.ToByteArray());
             }
             }
 
 
             if (!query.AppearsInItemId.Equals(Guid.Empty))
             if (!query.AppearsInItemId.Equals(Guid.Empty))
             {
             {
-                whereClauses.Add("Name in (Select Name from People where ItemId=@AppearsInItemId)");
-                if (statement != null)
-                {
-                    statement.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray());
-                }
+                whereClauses.Add("p.Name in (Select Name from People where ItemId=@AppearsInItemId)");
+                statement?.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray());
             }
             }
 
 
             var queryPersonTypes = query.PersonTypes.Where(IsValidPersonType).ToList();
             var queryPersonTypes = query.PersonTypes.Where(IsValidPersonType).ToList();
@@ -5107,10 +5108,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             if (queryPersonTypes.Count == 1)
             if (queryPersonTypes.Count == 1)
             {
             {
                 whereClauses.Add("PersonType=@PersonType");
                 whereClauses.Add("PersonType=@PersonType");
-                if (statement != null)
-                {
-                    statement.TryBind("@PersonType", queryPersonTypes[0]);
-                }
+                statement?.TryBind("@PersonType", queryPersonTypes[0]);
             }
             }
             else if (queryPersonTypes.Count > 1)
             else if (queryPersonTypes.Count > 1)
             {
             {
@@ -5124,10 +5122,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             if (queryExcludePersonTypes.Count == 1)
             if (queryExcludePersonTypes.Count == 1)
             {
             {
                 whereClauses.Add("PersonType<>@PersonType");
                 whereClauses.Add("PersonType<>@PersonType");
-                if (statement != null)
-                {
-                    statement.TryBind("@PersonType", queryExcludePersonTypes[0]);
-                }
+                statement?.TryBind("@PersonType", queryExcludePersonTypes[0]);
             }
             }
             else if (queryExcludePersonTypes.Count > 1)
             else if (queryExcludePersonTypes.Count > 1)
             {
             {
@@ -5139,19 +5134,24 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             if (query.MaxListOrder.HasValue)
             if (query.MaxListOrder.HasValue)
             {
             {
                 whereClauses.Add("ListOrder<=@MaxListOrder");
                 whereClauses.Add("ListOrder<=@MaxListOrder");
-                if (statement != null)
-                {
-                    statement.TryBind("@MaxListOrder", query.MaxListOrder.Value);
-                }
+                statement?.TryBind("@MaxListOrder", query.MaxListOrder.Value);
             }
             }
 
 
             if (!string.IsNullOrWhiteSpace(query.NameContains))
             if (!string.IsNullOrWhiteSpace(query.NameContains))
             {
             {
-                whereClauses.Add("Name like @NameContains");
-                if (statement != null)
-                {
-                    statement.TryBind("@NameContains", "%" + query.NameContains + "%");
-                }
+                whereClauses.Add("p.Name like @NameContains");
+                statement?.TryBind("@NameContains", "%" + query.NameContains + "%");
+            }
+
+            if (query.IsFavorite.HasValue)
+            {
+                whereClauses.Add("isFavorite=@IsFavorite");
+                statement?.TryBind("@IsFavorite", query.IsFavorite.Value);
+            }
+
+            if (query.User != null)
+            {
+                statement?.TryBind("@UserId", query.User.InternalId);
             }
             }
 
 
             return whereClauses;
             return whereClauses;
@@ -5420,6 +5420,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                 NameStartsWithOrGreater = query.NameStartsWithOrGreater,
                 NameStartsWithOrGreater = query.NameStartsWithOrGreater,
                 Tags = query.Tags,
                 Tags = query.Tags,
                 OfficialRatings = query.OfficialRatings,
                 OfficialRatings = query.OfficialRatings,
+                StudioIds = query.StudioIds,
                 GenreIds = query.GenreIds,
                 GenreIds = query.GenreIds,
                 Genres = query.Genres,
                 Genres = query.Genres,
                 Years = query.Years,
                 Years = query.Years,

+ 4 - 3
Emby.Server.Implementations/HttpServer/Security/AuthService.cs

@@ -1,6 +1,7 @@
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
 
 
@@ -19,12 +20,12 @@ namespace Emby.Server.Implementations.HttpServer.Security
         public AuthorizationInfo Authenticate(HttpRequest request)
         public AuthorizationInfo Authenticate(HttpRequest request)
         {
         {
             var auth = _authorizationContext.GetAuthorizationInfo(request);
             var auth = _authorizationContext.GetAuthorizationInfo(request);
-            if (auth?.User == null)
+            if (!auth.IsAuthenticated)
             {
             {
-                return null;
+                throw new AuthenticationException("Invalid token.");
             }
             }
 
 
-            if (auth.User.HasPermission(PermissionKind.IsDisabled))
+            if (auth.User?.HasPermission(PermissionKind.IsDisabled) ?? false)
             {
             {
                 throw new SecurityException("User account has been disabled.");
                 throw new SecurityException("User account has been disabled.");
             }
             }

+ 75 - 68
Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs

@@ -36,8 +36,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
         public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext)
         public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext)
         {
         {
             var auth = GetAuthorizationDictionary(requestContext);
             var auth = GetAuthorizationDictionary(requestContext);
-            var (authInfo, _) =
-                GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query);
+            var authInfo = GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query);
             return authInfo;
             return authInfo;
         }
         }
 
 
@@ -49,19 +48,13 @@ namespace Emby.Server.Implementations.HttpServer.Security
         private AuthorizationInfo GetAuthorization(HttpContext httpReq)
         private AuthorizationInfo GetAuthorization(HttpContext httpReq)
         {
         {
             var auth = GetAuthorizationDictionary(httpReq);
             var auth = GetAuthorizationDictionary(httpReq);
-            var (authInfo, originalAuthInfo) =
-                GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query);
-
-            if (originalAuthInfo != null)
-            {
-                httpReq.Request.HttpContext.Items["OriginalAuthenticationInfo"] = originalAuthInfo;
-            }
+            var authInfo = GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query);
 
 
             httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo;
             httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo;
             return authInfo;
             return authInfo;
         }
         }
 
 
-        private (AuthorizationInfo authInfo, AuthenticationInfo originalAuthenticationInfo) GetAuthorizationInfoFromDictionary(
+        private AuthorizationInfo GetAuthorizationInfoFromDictionary(
             in Dictionary<string, string> auth,
             in Dictionary<string, string> auth,
             in IHeaderDictionary headers,
             in IHeaderDictionary headers,
             in IQueryCollection queryString)
             in IQueryCollection queryString)
@@ -108,88 +101,102 @@ namespace Emby.Server.Implementations.HttpServer.Security
                 Device = device,
                 Device = device,
                 DeviceId = deviceId,
                 DeviceId = deviceId,
                 Version = version,
                 Version = version,
-                Token = token
+                Token = token,
+                IsAuthenticated = false
             };
             };
 
 
-            AuthenticationInfo originalAuthenticationInfo = null;
-            if (!string.IsNullOrWhiteSpace(token))
+            if (string.IsNullOrWhiteSpace(token))
             {
             {
-                var result = _authRepo.Get(new AuthenticationInfoQuery
-                {
-                    AccessToken = token
-                });
+                // Request doesn't contain a token.
+                return authInfo;
+            }
 
 
-                originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null;
+            var result = _authRepo.Get(new AuthenticationInfoQuery
+            {
+                AccessToken = token
+            });
 
 
-                if (originalAuthenticationInfo != null)
-                {
-                    var updateToken = false;
+            if (result.Items.Count > 0)
+            {
+                authInfo.IsAuthenticated = true;
+            }
 
 
-                    // TODO: Remove these checks for IsNullOrWhiteSpace
-                    if (string.IsNullOrWhiteSpace(authInfo.Client))
-                    {
-                        authInfo.Client = originalAuthenticationInfo.AppName;
-                    }
+            var originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null;
 
 
-                    if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
-                    {
-                        authInfo.DeviceId = originalAuthenticationInfo.DeviceId;
-                    }
+            if (originalAuthenticationInfo != null)
+            {
+                var updateToken = false;
 
 
-                    // Temporary. TODO - allow clients to specify that the token has been shared with a casting device
-                    var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1;
+                // TODO: Remove these checks for IsNullOrWhiteSpace
+                if (string.IsNullOrWhiteSpace(authInfo.Client))
+                {
+                    authInfo.Client = originalAuthenticationInfo.AppName;
+                }
 
 
-                    if (string.IsNullOrWhiteSpace(authInfo.Device))
-                    {
-                        authInfo.Device = originalAuthenticationInfo.DeviceName;
-                    }
-                    else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase))
-                    {
-                        if (allowTokenInfoUpdate)
-                        {
-                            updateToken = true;
-                            originalAuthenticationInfo.DeviceName = authInfo.Device;
-                        }
-                    }
+                if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
+                {
+                    authInfo.DeviceId = originalAuthenticationInfo.DeviceId;
+                }
 
 
-                    if (string.IsNullOrWhiteSpace(authInfo.Version))
-                    {
-                        authInfo.Version = originalAuthenticationInfo.AppVersion;
-                    }
-                    else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase))
+                // Temporary. TODO - allow clients to specify that the token has been shared with a casting device
+                var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1;
+
+                if (string.IsNullOrWhiteSpace(authInfo.Device))
+                {
+                    authInfo.Device = originalAuthenticationInfo.DeviceName;
+                }
+                else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase))
+                {
+                    if (allowTokenInfoUpdate)
                     {
                     {
-                        if (allowTokenInfoUpdate)
-                        {
-                            updateToken = true;
-                            originalAuthenticationInfo.AppVersion = authInfo.Version;
-                        }
+                        updateToken = true;
+                        originalAuthenticationInfo.DeviceName = authInfo.Device;
                     }
                     }
+                }
 
 
-                    if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3)
+                if (string.IsNullOrWhiteSpace(authInfo.Version))
+                {
+                    authInfo.Version = originalAuthenticationInfo.AppVersion;
+                }
+                else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase))
+                {
+                    if (allowTokenInfoUpdate)
                     {
                     {
-                        originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow;
                         updateToken = true;
                         updateToken = true;
+                        originalAuthenticationInfo.AppVersion = authInfo.Version;
                     }
                     }
+                }
 
 
-                    if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty))
-                    {
-                        authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId);
+                if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3)
+                {
+                    originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow;
+                    updateToken = true;
+                }
 
 
-                        if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase))
-                        {
-                            originalAuthenticationInfo.UserName = authInfo.User.Username;
-                            updateToken = true;
-                        }
-                    }
+                if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty))
+                {
+                    authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId);
 
 
-                    if (updateToken)
+                    if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase))
                     {
                     {
-                        _authRepo.Update(originalAuthenticationInfo);
+                        originalAuthenticationInfo.UserName = authInfo.User.Username;
+                        updateToken = true;
                     }
                     }
+
+                    authInfo.IsApiKey = true;
+                }
+                else
+                {
+                    authInfo.IsApiKey = false;
+                }
+
+                if (updateToken)
+                {
+                    _authRepo.Update(originalAuthenticationInfo);
                 }
                 }
             }
             }
 
 
-            return (authInfo, originalAuthenticationInfo);
+            return authInfo;
         }
         }
 
 
         /// <summary>
         /// <summary>

+ 15 - 0
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -2440,6 +2440,21 @@ namespace Emby.Server.Implementations.Library
             new SubtitleResolver(BaseItem.LocalizationManager).AddExternalSubtitleStreams(streams, videoPath, streams.Count, files);
             new SubtitleResolver(BaseItem.LocalizationManager).AddExternalSubtitleStreams(streams, videoPath, streams.Count, files);
         }
         }
 
 
+        public BaseItem GetParentItem(string parentId, Guid? userId)
+        {
+            if (!string.IsNullOrEmpty(parentId))
+            {
+                return GetItemById(new Guid(parentId));
+            }
+
+            if (userId.HasValue && userId != Guid.Empty)
+            {
+                return GetUserRootFolder();
+            }
+
+            return RootFolder;
+        }
+
         /// <inheritdoc />
         /// <inheritdoc />
         public bool IsVideoFile(string path)
         public bool IsVideoFile(string path)
         {
         {

+ 8 - 2
Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs

@@ -15,6 +15,7 @@ using System.Threading.Tasks;
 using MediaBrowser.Common;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Cryptography;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.LiveTv;
 using MediaBrowser.Model.LiveTv;
@@ -33,17 +34,20 @@ namespace Emby.Server.Implementations.LiveTv.Listings
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1);
         private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1);
         private readonly IApplicationHost _appHost;
         private readonly IApplicationHost _appHost;
+        private readonly ICryptoProvider _cryptoProvider;
 
 
         public SchedulesDirect(
         public SchedulesDirect(
             ILogger<SchedulesDirect> logger,
             ILogger<SchedulesDirect> logger,
             IJsonSerializer jsonSerializer,
             IJsonSerializer jsonSerializer,
             IHttpClientFactory httpClientFactory,
             IHttpClientFactory httpClientFactory,
-            IApplicationHost appHost)
+            IApplicationHost appHost,
+            ICryptoProvider cryptoProvider)
         {
         {
             _logger = logger;
             _logger = logger;
             _jsonSerializer = jsonSerializer;
             _jsonSerializer = jsonSerializer;
             _httpClientFactory = httpClientFactory;
             _httpClientFactory = httpClientFactory;
             _appHost = appHost;
             _appHost = appHost;
+            _cryptoProvider = cryptoProvider;
         }
         }
 
 
         private string UserAgent => _appHost.ApplicationUserAgent;
         private string UserAgent => _appHost.ApplicationUserAgent;
@@ -642,7 +646,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             CancellationToken cancellationToken)
             CancellationToken cancellationToken)
         {
         {
             using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
             using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
-            options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
+            var hashedPasswordBytes = _cryptoProvider.ComputeHash("SHA1", Encoding.ASCII.GetBytes(password), Array.Empty<byte>());
+            string hashedPassword = Hex.Encode(hashedPasswordBytes);
+            options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
 
 
             using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
             using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);

+ 5 - 0
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs

@@ -131,6 +131,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             await taskCompletionSource.Task.ConfigureAwait(false);
             await taskCompletionSource.Task.ConfigureAwait(false);
         }
         }
 
 
+        public string GetFilePath()
+        {
+            return TempFilePath;
+        }
+
         private Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
         private Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
         {
         {
             return Task.Run(async () =>
             return Task.Run(async () =>

+ 4 - 2
Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs

@@ -65,7 +65,9 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
         {
         {
             var channelIdPrefix = GetFullChannelIdPrefix(info);
             var channelIdPrefix = GetFullChannelIdPrefix(info);
 
 
-            return await new M3uParser(Logger, _httpClientFactory, _appHost).Parse(info.Url, channelIdPrefix, info.Id, cancellationToken).ConfigureAwait(false);
+            return await new M3uParser(Logger, _httpClientFactory, _appHost)
+                .Parse(info, channelIdPrefix, cancellationToken)
+                .ConfigureAwait(false);
         }
         }
 
 
         public Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken)
         public Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken)
@@ -126,7 +128,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
 
 
         public async Task Validate(TunerHostInfo info)
         public async Task Validate(TunerHostInfo info)
         {
         {
-            using (var stream = await new M3uParser(Logger, _httpClientFactory, _appHost).GetListingsStream(info.Url, CancellationToken.None).ConfigureAwait(false))
+            using (var stream = await new M3uParser(Logger, _httpClientFactory, _appHost).GetListingsStream(info, CancellationToken.None).ConfigureAwait(false))
             {
             {
             }
             }
         }
         }

+ 18 - 8
Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs

@@ -13,6 +13,7 @@ using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.LiveTv;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 
 
 namespace Emby.Server.Implementations.LiveTv.TunerHosts
 namespace Emby.Server.Implementations.LiveTv.TunerHosts
@@ -30,12 +31,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
             _appHost = appHost;
             _appHost = appHost;
         }
         }
 
 
-        public async Task<List<ChannelInfo>> Parse(string url, string channelIdPrefix, string tunerHostId, CancellationToken cancellationToken)
+        public async Task<List<ChannelInfo>> Parse(TunerHostInfo info, string channelIdPrefix, CancellationToken cancellationToken)
         {
         {
             // Read the file and display it line by line.
             // Read the file and display it line by line.
-            using (var reader = new StreamReader(await GetListingsStream(url, cancellationToken).ConfigureAwait(false)))
+            using (var reader = new StreamReader(await GetListingsStream(info, cancellationToken).ConfigureAwait(false)))
             {
             {
-                return GetChannels(reader, channelIdPrefix, tunerHostId);
+                return GetChannels(reader, channelIdPrefix, info.Id);
             }
             }
         }
         }
 
 
@@ -48,15 +49,24 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
             }
             }
         }
         }
 
 
-        public Task<Stream> GetListingsStream(string url, CancellationToken cancellationToken)
+        public async Task<Stream> GetListingsStream(TunerHostInfo info, CancellationToken cancellationToken)
         {
         {
-            if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+            if (info.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
             {
             {
-                return _httpClientFactory.CreateClient(NamedClient.Default)
-                    .GetStreamAsync(url);
+                using var requestMessage = new HttpRequestMessage(HttpMethod.Get, info.Url);
+                if (!string.IsNullOrEmpty(info.UserAgent))
+                {
+                    requestMessage.Headers.UserAgent.TryParseAdd(info.UserAgent);
+                }
+
+                var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+                    .SendAsync(requestMessage, cancellationToken)
+                    .ConfigureAwait(false);
+                response.EnsureSuccessStatusCode();
+                return await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
             }
             }
 
 
-            return Task.FromResult((Stream)File.OpenRead(url));
+            return File.OpenRead(info.Url);
         }
         }
 
 
         private const string ExtInfPrefix = "#EXTINF:";
         private const string ExtInfPrefix = "#EXTINF:";

+ 7 - 1
Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs

@@ -55,7 +55,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
             var typeName = GetType().Name;
             var typeName = GetType().Name;
             Logger.LogInformation("Opening " + typeName + " Live stream from {0}", url);
             Logger.LogInformation("Opening " + typeName + " Live stream from {0}", url);
 
 
-            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+            // Response stream is disposed manually.
+            var response = await _httpClientFactory.CreateClient(NamedClient.Default)
                 .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None)
                 .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None)
                 .ConfigureAwait(false);
                 .ConfigureAwait(false);
 
 
@@ -121,6 +122,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
             }
             }
         }
         }
 
 
+        public string GetFilePath()
+        {
+            return TempFilePath;
+        }
+
         private Task StartStreaming(HttpResponseMessage response, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
         private Task StartStreaming(HttpResponseMessage response, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
         {
         {
             return Task.Run(async () =>
             return Task.Run(async () =>

+ 3 - 1
Emby.Server.Implementations/Localization/Core/cs.json

@@ -113,5 +113,7 @@
     "TasksChannelsCategory": "Internetové kanály",
     "TasksChannelsCategory": "Internetové kanály",
     "TasksApplicationCategory": "Aplikace",
     "TasksApplicationCategory": "Aplikace",
     "TasksLibraryCategory": "Knihovna",
     "TasksLibraryCategory": "Knihovna",
-    "TasksMaintenanceCategory": "Údržba"
+    "TasksMaintenanceCategory": "Údržba",
+    "TaskCleanActivityLogDescription": "Smazat záznamy o aktivitě, které jsou starší než zadaná doba.",
+    "TaskCleanActivityLog": "Smazat záznam aktivity"
 }
 }

+ 3 - 1
Emby.Server.Implementations/Localization/Core/de.json

@@ -113,5 +113,7 @@
     "TasksChannelsCategory": "Internet Kanäle",
     "TasksChannelsCategory": "Internet Kanäle",
     "TasksApplicationCategory": "Anwendung",
     "TasksApplicationCategory": "Anwendung",
     "TasksLibraryCategory": "Bibliothek",
     "TasksLibraryCategory": "Bibliothek",
-    "TasksMaintenanceCategory": "Wartung"
+    "TasksMaintenanceCategory": "Wartung",
+    "TaskCleanActivityLogDescription": "Löscht Aktivitätsprotokolleinträge, die älter als das konfigurierte Alter sind.",
+    "TaskCleanActivityLog": "Aktivitätsprotokoll aufräumen"
 }
 }

+ 1 - 1
Emby.Server.Implementations/Localization/Core/es.json

@@ -77,7 +77,7 @@
     "SubtitleDownloadFailureFromForItem": "Fallo de descarga de subtítulos desde {0} para {1}",
     "SubtitleDownloadFailureFromForItem": "Fallo de descarga de subtítulos desde {0} para {1}",
     "Sync": "Sincronizar",
     "Sync": "Sincronizar",
     "System": "Sistema",
     "System": "Sistema",
-    "TvShows": "Programas de televisión",
+    "TvShows": "Series",
     "User": "Usuario",
     "User": "Usuario",
     "UserCreatedWithName": "El usuario {0} ha sido creado",
     "UserCreatedWithName": "El usuario {0} ha sido creado",
     "UserDeletedWithName": "El usuario {0} ha sido borrado",
     "UserDeletedWithName": "El usuario {0} ha sido borrado",

+ 3 - 1
Emby.Server.Implementations/Localization/Core/fr.json

@@ -113,5 +113,7 @@
     "TaskCleanCache": "Vider le répertoire cache",
     "TaskCleanCache": "Vider le répertoire cache",
     "TasksApplicationCategory": "Application",
     "TasksApplicationCategory": "Application",
     "TasksLibraryCategory": "Bibliothèque",
     "TasksLibraryCategory": "Bibliothèque",
-    "TasksMaintenanceCategory": "Maintenance"
+    "TasksMaintenanceCategory": "Maintenance",
+    "TaskCleanActivityLogDescription": "Supprime les entrées du journal d'activité antérieures à l'âge configuré.",
+    "TaskCleanActivityLog": "Nettoyer le journal d'activité"
 }
 }

+ 61 - 59
Emby.Server.Implementations/Localization/Core/hr.json

@@ -5,13 +5,13 @@
     "Artists": "Izvođači",
     "Artists": "Izvođači",
     "AuthenticationSucceededWithUserName": "{0} uspješno ovjerena",
     "AuthenticationSucceededWithUserName": "{0} uspješno ovjerena",
     "Books": "Knjige",
     "Books": "Knjige",
-    "CameraImageUploadedFrom": "Nova fotografija sa kamere je uploadana iz {0}",
+    "CameraImageUploadedFrom": "Nova fotografija sa kamere je učitana iz {0}",
     "Channels": "Kanali",
     "Channels": "Kanali",
     "ChapterNameValue": "Poglavlje {0}",
     "ChapterNameValue": "Poglavlje {0}",
     "Collections": "Kolekcije",
     "Collections": "Kolekcije",
-    "DeviceOfflineWithName": "{0} se odspojilo",
-    "DeviceOnlineWithName": "{0} je spojeno",
-    "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave za {0}",
+    "DeviceOfflineWithName": "{0} je prekinuo vezu",
+    "DeviceOnlineWithName": "{0} je povezan",
+    "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave od {0}",
     "Favorites": "Favoriti",
     "Favorites": "Favoriti",
     "Folders": "Mape",
     "Folders": "Mape",
     "Genres": "Žanrovi",
     "Genres": "Žanrovi",
@@ -23,95 +23,97 @@
     "HeaderFavoriteShows": "Omiljene serije",
     "HeaderFavoriteShows": "Omiljene serije",
     "HeaderFavoriteSongs": "Omiljene pjesme",
     "HeaderFavoriteSongs": "Omiljene pjesme",
     "HeaderLiveTV": "TV uživo",
     "HeaderLiveTV": "TV uživo",
-    "HeaderNextUp": "Sljedeće je",
+    "HeaderNextUp": "Slijedi",
     "HeaderRecordingGroups": "Grupa snimka",
     "HeaderRecordingGroups": "Grupa snimka",
-    "HomeVideos": "Kućni videi",
+    "HomeVideos": "Kućni video",
     "Inherit": "Naslijedi",
     "Inherit": "Naslijedi",
     "ItemAddedWithName": "{0} je dodano u biblioteku",
     "ItemAddedWithName": "{0} je dodano u biblioteku",
-    "ItemRemovedWithName": "{0} je uklonjen iz biblioteke",
+    "ItemRemovedWithName": "{0} je uklonjeno iz biblioteke",
     "LabelIpAddressValue": "IP adresa: {0}",
     "LabelIpAddressValue": "IP adresa: {0}",
     "LabelRunningTimeValue": "Vrijeme rada: {0}",
     "LabelRunningTimeValue": "Vrijeme rada: {0}",
     "Latest": "Najnovije",
     "Latest": "Najnovije",
-    "MessageApplicationUpdated": "Jellyfin Server je ažuriran",
-    "MessageApplicationUpdatedTo": "Jellyfin Server je ažuriran na {0}",
-    "MessageNamedServerConfigurationUpdatedWithValue": "Odjeljak postavka servera {0} je ažuriran",
-    "MessageServerConfigurationUpdated": "Postavke servera su ažurirane",
+    "MessageApplicationUpdated": "Jellyfin server je ažuriran",
+    "MessageApplicationUpdatedTo": "Jellyfin server je ažuriran na {0}",
+    "MessageNamedServerConfigurationUpdatedWithValue": "Dio konfiguracije servera {0} je ažuriran",
+    "MessageServerConfigurationUpdated": "Konfiguracija servera je ažurirana",
     "MixedContent": "Miješani sadržaj",
     "MixedContent": "Miješani sadržaj",
     "Movies": "Filmovi",
     "Movies": "Filmovi",
     "Music": "Glazba",
     "Music": "Glazba",
     "MusicVideos": "Glazbeni spotovi",
     "MusicVideos": "Glazbeni spotovi",
     "NameInstallFailed": "{0} neuspješnih instalacija",
     "NameInstallFailed": "{0} neuspješnih instalacija",
     "NameSeasonNumber": "Sezona {0}",
     "NameSeasonNumber": "Sezona {0}",
-    "NameSeasonUnknown": "Nepoznata sezona",
+    "NameSeasonUnknown": "Sezona nepoznata",
     "NewVersionIsAvailable": "Nova verzija Jellyfin servera je dostupna za preuzimanje.",
     "NewVersionIsAvailable": "Nova verzija Jellyfin servera je dostupna za preuzimanje.",
-    "NotificationOptionApplicationUpdateAvailable": "Dostupno ažuriranje aplikacije",
-    "NotificationOptionApplicationUpdateInstalled": "Instalirano ažuriranje aplikacije",
-    "NotificationOptionAudioPlayback": "Reprodukcija glazbe započeta",
-    "NotificationOptionAudioPlaybackStopped": "Reprodukcija audiozapisa je zaustavljena",
-    "NotificationOptionCameraImageUploaded": "Slike kamere preuzete",
-    "NotificationOptionInstallationFailed": "Instalacija neuspješna",
-    "NotificationOptionNewLibraryContent": "Novi sadržaj je dodan",
-    "NotificationOptionPluginError": "Dodatak otkazao",
+    "NotificationOptionApplicationUpdateAvailable": "Dostupno je ažuriranje aplikacije",
+    "NotificationOptionApplicationUpdateInstalled": "Instalirano je ažuriranje aplikacije",
+    "NotificationOptionAudioPlayback": "Reprodukcija glazbe započela",
+    "NotificationOptionAudioPlaybackStopped": "Reprodukcija glazbe zaustavljena",
+    "NotificationOptionCameraImageUploaded": "Slika s kamere učitana",
+    "NotificationOptionInstallationFailed": "Instalacija nije uspjela",
+    "NotificationOptionNewLibraryContent": "Novi sadržaj dodan",
+    "NotificationOptionPluginError": "Dodatak zakazao",
     "NotificationOptionPluginInstalled": "Dodatak instaliran",
     "NotificationOptionPluginInstalled": "Dodatak instaliran",
-    "NotificationOptionPluginUninstalled": "Dodatak uklonjen",
-    "NotificationOptionPluginUpdateInstalled": "Instalirano ažuriranje za dodatak",
-    "NotificationOptionServerRestartRequired": "Potrebno ponovo pokretanje servera",
-    "NotificationOptionTaskFailed": "Zakazan zadatak nije izvršen",
+    "NotificationOptionPluginUninstalled": "Dodatak deinstaliran",
+    "NotificationOptionPluginUpdateInstalled": "Instalirano ažuriranje dodatka",
+    "NotificationOptionServerRestartRequired": "Ponovno pokrenite server",
+    "NotificationOptionTaskFailed": "Greška zakazanog zadatka",
     "NotificationOptionUserLockedOut": "Korisnik zaključan",
     "NotificationOptionUserLockedOut": "Korisnik zaključan",
-    "NotificationOptionVideoPlayback": "Reprodukcija videa započeta",
-    "NotificationOptionVideoPlaybackStopped": "Reprodukcija videozapisa je zaustavljena",
-    "Photos": "Slike",
-    "Playlists": "Popis za reprodukciju",
+    "NotificationOptionVideoPlayback": "Reprodukcija videa započela",
+    "NotificationOptionVideoPlaybackStopped": "Reprodukcija videa zaustavljena",
+    "Photos": "Fotografije",
+    "Playlists": "Popisi za reprodukciju",
     "Plugin": "Dodatak",
     "Plugin": "Dodatak",
     "PluginInstalledWithName": "{0} je instalirano",
     "PluginInstalledWithName": "{0} je instalirano",
     "PluginUninstalledWithName": "{0} je deinstalirano",
     "PluginUninstalledWithName": "{0} je deinstalirano",
     "PluginUpdatedWithName": "{0} je ažurirano",
     "PluginUpdatedWithName": "{0} je ažurirano",
-    "ProviderValue": "Pružitelj: {0}",
+    "ProviderValue": "Pružatelj: {0}",
     "ScheduledTaskFailedWithName": "{0} neuspjelo",
     "ScheduledTaskFailedWithName": "{0} neuspjelo",
     "ScheduledTaskStartedWithName": "{0} pokrenuto",
     "ScheduledTaskStartedWithName": "{0} pokrenuto",
-    "ServerNameNeedsToBeRestarted": "{0} treba biti ponovno pokrenuto",
+    "ServerNameNeedsToBeRestarted": "{0} treba ponovno pokrenuti",
     "Shows": "Serije",
     "Shows": "Serije",
     "Songs": "Pjesme",
     "Songs": "Pjesme",
-    "StartupEmbyServerIsLoading": "Jellyfin Server se učitava. Pokušajte ponovo kasnije.",
+    "StartupEmbyServerIsLoading": "Jellyfin server se učitava. Pokušajte ponovo uskoro.",
     "SubtitleDownloadFailureForItem": "Titlovi prijevoda nisu preuzeti za {0}",
     "SubtitleDownloadFailureForItem": "Titlovi prijevoda nisu preuzeti za {0}",
-    "SubtitleDownloadFailureFromForItem": "Prijevodi nisu uspješno preuzeti {0} od {1}",
-    "Sync": "Sink.",
-    "System": "Sistem",
+    "SubtitleDownloadFailureFromForItem": "Prijevod nije uspješno preuzet od {0} za {1}",
+    "Sync": "Sinkronizacija",
+    "System": "Sustav",
     "TvShows": "Serije",
     "TvShows": "Serije",
     "User": "Korisnik",
     "User": "Korisnik",
-    "UserCreatedWithName": "Korisnik {0} je stvoren",
+    "UserCreatedWithName": "Korisnik {0} je kreiran",
     "UserDeletedWithName": "Korisnik {0} je obrisan",
     "UserDeletedWithName": "Korisnik {0} je obrisan",
-    "UserDownloadingItemWithValues": "{0} se preuzima {1}",
+    "UserDownloadingItemWithValues": "{0} preuzima {1}",
     "UserLockedOutWithName": "Korisnik {0} je zaključan",
     "UserLockedOutWithName": "Korisnik {0} je zaključan",
-    "UserOfflineFromDevice": "{0} se odspojilo od {1}",
-    "UserOnlineFromDevice": "{0} je online od {1}",
+    "UserOfflineFromDevice": "{0} prekinuo vezu od {1}",
+    "UserOnlineFromDevice": "{0} povezan od {1}",
     "UserPasswordChangedWithName": "Lozinka je promijenjena za korisnika {0}",
     "UserPasswordChangedWithName": "Lozinka je promijenjena za korisnika {0}",
-    "UserPolicyUpdatedWithName": "Pravila za korisnika su ažurirana za {0}",
-    "UserStartedPlayingItemWithValues": "{0} je pokrenuo {1}",
-    "UserStoppedPlayingItemWithValues": "{0} je zaustavio {1}",
+    "UserPolicyUpdatedWithName": "Pravila za korisnika ažurirana su za {0}",
+    "UserStartedPlayingItemWithValues": "{0} je pokrenuo reprodukciju {1} na {2}",
+    "UserStoppedPlayingItemWithValues": "{0} je zavio reprodukciju {1} na {2}",
     "ValueHasBeenAddedToLibrary": "{0} je dodano u medijsku biblioteku",
     "ValueHasBeenAddedToLibrary": "{0} je dodano u medijsku biblioteku",
-    "ValueSpecialEpisodeName": "Specijal - {0}",
+    "ValueSpecialEpisodeName": "Posebno - {0}",
     "VersionNumber": "Verzija {0}",
     "VersionNumber": "Verzija {0}",
-    "TaskRefreshLibraryDescription": "Skenira vašu medijsku knjižnicu sa novim datotekama i osvježuje metapodatke.",
-    "TaskRefreshLibrary": "Skeniraj medijsku knjižnicu",
-    "TaskRefreshChapterImagesDescription": "Stvara sličice za videozapise koji imaju poglavlja.",
-    "TaskRefreshChapterImages": "Raspakiraj slike poglavlja",
-    "TaskCleanCacheDescription": "Briše priručne datoteke nepotrebne za sistem.",
-    "TaskCleanCache": "Očisti priručnu memoriju",
+    "TaskRefreshLibraryDescription": "Skenira medijsku biblioteku radi novih datoteka i osvježava metapodatke.",
+    "TaskRefreshLibrary": "Skeniraj medijsku biblioteku",
+    "TaskRefreshChapterImagesDescription": "Kreira sličice za videozapise koji imaju poglavlja.",
+    "TaskRefreshChapterImages": "Izdvoji slike poglavlja",
+    "TaskCleanCacheDescription": "Briše nepotrebne datoteke iz predmemorije.",
+    "TaskCleanCache": "Očisti mapu predmemorije",
     "TasksApplicationCategory": "Aplikacija",
     "TasksApplicationCategory": "Aplikacija",
     "TasksMaintenanceCategory": "Održavanje",
     "TasksMaintenanceCategory": "Održavanje",
-    "TaskDownloadMissingSubtitlesDescription": "Pretraživanje interneta za prijevodima koji nedostaju bazirano na konfiguraciji meta podataka.",
-    "TaskDownloadMissingSubtitles": "Preuzimanje prijevoda koji nedostaju",
-    "TaskRefreshChannelsDescription": "Osvježava informacije o internet kanalima.",
+    "TaskDownloadMissingSubtitlesDescription": "Pretraži Internet za prijevodima koji nedostaju prema konfiguraciji metapodataka.",
+    "TaskDownloadMissingSubtitles": "Preuzmi prijevod koji nedostaje",
+    "TaskRefreshChannelsDescription": "Osvježava informacije Internet kanala.",
     "TaskRefreshChannels": "Osvježi kanale",
     "TaskRefreshChannels": "Osvježi kanale",
-    "TaskCleanTranscodeDescription": "Briše transkodirane fajlove starije od jednog dana.",
-    "TaskCleanTranscode": "Očisti direktorij za transkodiranje",
-    "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja za dodatke koji su podešeni da se ažuriraju automatski.",
+    "TaskCleanTranscodeDescription": "Briše transkodirane datoteke starije od jednog dana.",
+    "TaskCleanTranscode": "Očisti mapu transkodiranja",
+    "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja za dodatke koji su konfigurirani da se ažuriraju automatski.",
     "TaskUpdatePlugins": "Ažuriraj dodatke",
     "TaskUpdatePlugins": "Ažuriraj dodatke",
-    "TaskRefreshPeopleDescription": "Ažurira meta podatke za glumce i redatelje u vašoj medijskoj biblioteci.",
-    "TaskRefreshPeople": "Osvježi ljude",
-    "TaskCleanLogsDescription": "Briši logove koji su stariji od {0} dana.",
-    "TaskCleanLogs": "Očisti direktorij sa logovima",
+    "TaskRefreshPeopleDescription": "Ažurira metapodatke za glumce i redatelje u medijskoj biblioteci.",
+    "TaskRefreshPeople": "Osvježi osobe",
+    "TaskCleanLogsDescription": "Briše zapise dnevnika koji su stariji od {0} dana.",
+    "TaskCleanLogs": "Očisti mapu dnevnika zapisa",
     "TasksChannelsCategory": "Internet kanali",
     "TasksChannelsCategory": "Internet kanali",
-    "TasksLibraryCategory": "Biblioteka"
+    "TasksLibraryCategory": "Biblioteka",
+    "TaskCleanActivityLogDescription": "Briše zapise dnevnika aktivnosti starije od navedenog vremena.",
+    "TaskCleanActivityLog": "Očisti dnevnik aktivnosti"
 }
 }

+ 3 - 1
Emby.Server.Implementations/Localization/Core/it.json

@@ -113,5 +113,7 @@
     "TasksChannelsCategory": "Canali su Internet",
     "TasksChannelsCategory": "Canali su Internet",
     "TasksApplicationCategory": "Applicazione",
     "TasksApplicationCategory": "Applicazione",
     "TasksLibraryCategory": "Libreria",
     "TasksLibraryCategory": "Libreria",
-    "TasksMaintenanceCategory": "Manutenzione"
+    "TasksMaintenanceCategory": "Manutenzione",
+    "TaskCleanActivityLog": "Attività di Registro Completate",
+    "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata."
 }
 }

+ 4 - 2
Emby.Server.Implementations/Localization/Core/ja.json

@@ -96,7 +96,7 @@
     "TaskRefreshLibraryDescription": "メディアライブラリをスキャンして新しいファイルを探し、メタデータをリフレッシュします。",
     "TaskRefreshLibraryDescription": "メディアライブラリをスキャンして新しいファイルを探し、メタデータをリフレッシュします。",
     "TaskRefreshLibrary": "メディアライブラリのスキャン",
     "TaskRefreshLibrary": "メディアライブラリのスキャン",
     "TaskCleanCacheDescription": "不要なキャッシュを消去します。",
     "TaskCleanCacheDescription": "不要なキャッシュを消去します。",
-    "TaskCleanCache": "キャッシュの掃除",
+    "TaskCleanCache": "キャッシュを消去",
     "TasksChannelsCategory": "ネットチャンネル",
     "TasksChannelsCategory": "ネットチャンネル",
     "TasksApplicationCategory": "アプリケーション",
     "TasksApplicationCategory": "アプリケーション",
     "TasksLibraryCategory": "ライブラリ",
     "TasksLibraryCategory": "ライブラリ",
@@ -112,5 +112,7 @@
     "TaskDownloadMissingSubtitlesDescription": "メタデータ構成に基づいて、欠落している字幕をインターネットで検索します。",
     "TaskDownloadMissingSubtitlesDescription": "メタデータ構成に基づいて、欠落している字幕をインターネットで検索します。",
     "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネイルを作成します。",
     "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネイルを作成します。",
     "TaskRefreshChapterImages": "チャプター画像を抽出する",
     "TaskRefreshChapterImages": "チャプター画像を抽出する",
-    "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする"
+    "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする",
+    "TaskCleanActivityLogDescription": "設定された期間よりも古いアクティビティの履歴を削除します。",
+    "TaskCleanActivityLog": "アクティビティの履歴を消去"
 }
 }

+ 4 - 2
Emby.Server.Implementations/Localization/Core/ko.json

@@ -27,7 +27,7 @@
     "HeaderRecordingGroups": "녹화 그룹",
     "HeaderRecordingGroups": "녹화 그룹",
     "HomeVideos": "홈 비디오",
     "HomeVideos": "홈 비디오",
     "Inherit": "상속",
     "Inherit": "상속",
-    "ItemAddedWithName": "{0}가 라이브러리에 추가",
+    "ItemAddedWithName": "{0}가 라이브러리에 추가되었습니다",
     "ItemRemovedWithName": "{0}가 라이브러리에서 제거됨",
     "ItemRemovedWithName": "{0}가 라이브러리에서 제거됨",
     "LabelIpAddressValue": "IP 주소: {0}",
     "LabelIpAddressValue": "IP 주소: {0}",
     "LabelRunningTimeValue": "상영 시간: {0}",
     "LabelRunningTimeValue": "상영 시간: {0}",
@@ -113,5 +113,7 @@
     "TaskCleanCacheDescription": "시스템에서 더 이상 필요하지 않은 캐시 파일을 삭제합니다.",
     "TaskCleanCacheDescription": "시스템에서 더 이상 필요하지 않은 캐시 파일을 삭제합니다.",
     "TaskCleanCache": "캐시 폴더 청소",
     "TaskCleanCache": "캐시 폴더 청소",
     "TasksChannelsCategory": "인터넷 채널",
     "TasksChannelsCategory": "인터넷 채널",
-    "TasksLibraryCategory": "라이브러리"
+    "TasksLibraryCategory": "라이브러리",
+    "TaskCleanActivityLogDescription": "구성된 기간보다 오래된 활동내역 삭제.",
+    "TaskCleanActivityLog": "활동내역청소"
 }
 }

+ 3 - 1
Emby.Server.Implementations/Localization/Core/ru.json

@@ -113,5 +113,7 @@
     "TaskCleanLogsDescription": "Удаляются файлы журнала, возраст которых превышает {0} дн(я/ей).",
     "TaskCleanLogsDescription": "Удаляются файлы журнала, возраст которых превышает {0} дн(я/ей).",
     "TaskRefreshLibraryDescription": "Сканируется медиатека на новые файлы и обновляются метаданные.",
     "TaskRefreshLibraryDescription": "Сканируется медиатека на новые файлы и обновляются метаданные.",
     "TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.",
     "TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.",
-    "TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе."
+    "TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе.",
+    "TaskCleanActivityLogDescription": "Удаляет записи журнала активности старше установленного возраста.",
+    "TaskCleanActivityLog": "Очистить журнал активности"
 }
 }

+ 21 - 21
Emby.Server.Implementations/Localization/Core/sl-SI.json

@@ -3,20 +3,20 @@
     "AppDeviceValues": "Aplikacija: {0}, Naprava: {1}",
     "AppDeviceValues": "Aplikacija: {0}, Naprava: {1}",
     "Application": "Aplikacija",
     "Application": "Aplikacija",
     "Artists": "Izvajalci",
     "Artists": "Izvajalci",
-    "AuthenticationSucceededWithUserName": "{0} preverjanje pristnosti uspešno",
+    "AuthenticationSucceededWithUserName": "{0} se je uspešno prijavil",
     "Books": "Knjige",
     "Books": "Knjige",
-    "CameraImageUploadedFrom": "Nova fotografija je bila naložena z {0}",
+    "CameraImageUploadedFrom": "Nova fotografija je bila naložena iz {0}",
     "Channels": "Kanali",
     "Channels": "Kanali",
     "ChapterNameValue": "Poglavje {0}",
     "ChapterNameValue": "Poglavje {0}",
     "Collections": "Zbirke",
     "Collections": "Zbirke",
     "DeviceOfflineWithName": "{0} je prekinil povezavo",
     "DeviceOfflineWithName": "{0} je prekinil povezavo",
     "DeviceOnlineWithName": "{0} je povezan",
     "DeviceOnlineWithName": "{0} je povezan",
-    "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave z {0}",
+    "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave iz {0}",
     "Favorites": "Priljubljeno",
     "Favorites": "Priljubljeno",
     "Folders": "Mape",
     "Folders": "Mape",
     "Genres": "Zvrsti",
     "Genres": "Zvrsti",
     "HeaderAlbumArtists": "Izvajalci albuma",
     "HeaderAlbumArtists": "Izvajalci albuma",
-    "HeaderContinueWatching": "Nadaljuj gledanje",
+    "HeaderContinueWatching": "Nadaljuj z ogledom",
     "HeaderFavoriteAlbums": "Priljubljeni albumi",
     "HeaderFavoriteAlbums": "Priljubljeni albumi",
     "HeaderFavoriteArtists": "Priljubljeni izvajalci",
     "HeaderFavoriteArtists": "Priljubljeni izvajalci",
     "HeaderFavoriteEpisodes": "Priljubljene epizode",
     "HeaderFavoriteEpisodes": "Priljubljene epizode",
@@ -32,23 +32,23 @@
     "LabelIpAddressValue": "IP naslov: {0}",
     "LabelIpAddressValue": "IP naslov: {0}",
     "LabelRunningTimeValue": "Čas trajanja: {0}",
     "LabelRunningTimeValue": "Čas trajanja: {0}",
     "Latest": "Najnovejše",
     "Latest": "Najnovejše",
-    "MessageApplicationUpdated": "Jellyfin Server je bil posodobljen",
-    "MessageApplicationUpdatedTo": "Jellyfin Server je bil posodobljen na {0}",
-    "MessageNamedServerConfigurationUpdatedWithValue": "Oddelek nastavitve strežnika {0} je bil posodobljen",
+    "MessageApplicationUpdated": "Jellyfin strežnik je bil posodobljen",
+    "MessageApplicationUpdatedTo": "Jellyfin strežnik je bil posodobljen na {0}",
+    "MessageNamedServerConfigurationUpdatedWithValue": "Oddelek nastavitev {0} je bil posodobljen",
     "MessageServerConfigurationUpdated": "Nastavitve strežnika so bile posodobljene",
     "MessageServerConfigurationUpdated": "Nastavitve strežnika so bile posodobljene",
-    "MixedContent": "Razne vsebine",
+    "MixedContent": "Mešane vsebine",
     "Movies": "Filmi",
     "Movies": "Filmi",
     "Music": "Glasba",
     "Music": "Glasba",
     "MusicVideos": "Glasbeni videi",
     "MusicVideos": "Glasbeni videi",
     "NameInstallFailed": "{0} namestitev neuspešna",
     "NameInstallFailed": "{0} namestitev neuspešna",
     "NameSeasonNumber": "Sezona {0}",
     "NameSeasonNumber": "Sezona {0}",
-    "NameSeasonUnknown": "Season neznana",
+    "NameSeasonUnknown": "Neznana sezona",
     "NewVersionIsAvailable": "Nova različica Jellyfin strežnika je na voljo za prenos.",
     "NewVersionIsAvailable": "Nova različica Jellyfin strežnika je na voljo za prenos.",
     "NotificationOptionApplicationUpdateAvailable": "Posodobitev aplikacije je na voljo",
     "NotificationOptionApplicationUpdateAvailable": "Posodobitev aplikacije je na voljo",
     "NotificationOptionApplicationUpdateInstalled": "Posodobitev aplikacije je bila nameščena",
     "NotificationOptionApplicationUpdateInstalled": "Posodobitev aplikacije je bila nameščena",
-    "NotificationOptionAudioPlayback": "Predvajanje zvoka začeto",
-    "NotificationOptionAudioPlaybackStopped": "Predvajanje zvoka zaustavljeno",
-    "NotificationOptionCameraImageUploaded": "Posnetek kamere naložen",
+    "NotificationOptionAudioPlayback": "Predvajanje zvoka se je začelo",
+    "NotificationOptionAudioPlaybackStopped": "Predvajanje zvoka se je ustavilo",
+    "NotificationOptionCameraImageUploaded": "Fotografija naložena",
     "NotificationOptionInstallationFailed": "Namestitev neuspešna",
     "NotificationOptionInstallationFailed": "Namestitev neuspešna",
     "NotificationOptionNewLibraryContent": "Nove vsebine dodane",
     "NotificationOptionNewLibraryContent": "Nove vsebine dodane",
     "NotificationOptionPluginError": "Napaka dodatka",
     "NotificationOptionPluginError": "Napaka dodatka",
@@ -56,41 +56,41 @@
     "NotificationOptionPluginUninstalled": "Dodatek odstranjen",
     "NotificationOptionPluginUninstalled": "Dodatek odstranjen",
     "NotificationOptionPluginUpdateInstalled": "Posodobitev dodatka nameščena",
     "NotificationOptionPluginUpdateInstalled": "Posodobitev dodatka nameščena",
     "NotificationOptionServerRestartRequired": "Potreben je ponovni zagon strežnika",
     "NotificationOptionServerRestartRequired": "Potreben je ponovni zagon strežnika",
-    "NotificationOptionTaskFailed": "Razporejena naloga neuspešna",
+    "NotificationOptionTaskFailed": "Načrtovano opravilo neuspešno",
     "NotificationOptionUserLockedOut": "Uporabnik zaklenjen",
     "NotificationOptionUserLockedOut": "Uporabnik zaklenjen",
     "NotificationOptionVideoPlayback": "Predvajanje videa se je začelo",
     "NotificationOptionVideoPlayback": "Predvajanje videa se je začelo",
     "NotificationOptionVideoPlaybackStopped": "Predvajanje videa se je ustavilo",
     "NotificationOptionVideoPlaybackStopped": "Predvajanje videa se je ustavilo",
     "Photos": "Fotografije",
     "Photos": "Fotografije",
     "Playlists": "Seznami predvajanja",
     "Playlists": "Seznami predvajanja",
-    "Plugin": "Plugin",
+    "Plugin": "Dodatek",
     "PluginInstalledWithName": "{0} je bil nameščen",
     "PluginInstalledWithName": "{0} je bil nameščen",
     "PluginUninstalledWithName": "{0} je bil odstranjen",
     "PluginUninstalledWithName": "{0} je bil odstranjen",
     "PluginUpdatedWithName": "{0} je bil posodobljen",
     "PluginUpdatedWithName": "{0} je bil posodobljen",
-    "ProviderValue": "Provider: {0}",
+    "ProviderValue": "Ponudnik: {0}",
     "ScheduledTaskFailedWithName": "{0} ni uspelo",
     "ScheduledTaskFailedWithName": "{0} ni uspelo",
     "ScheduledTaskStartedWithName": "{0} začeto",
     "ScheduledTaskStartedWithName": "{0} začeto",
     "ServerNameNeedsToBeRestarted": "{0} mora biti ponovno zagnan",
     "ServerNameNeedsToBeRestarted": "{0} mora biti ponovno zagnan",
     "Shows": "Serije",
     "Shows": "Serije",
     "Songs": "Pesmi",
     "Songs": "Pesmi",
-    "StartupEmbyServerIsLoading": "Jellyfin Server se nalaga. Poskusi ponovno kasneje.",
+    "StartupEmbyServerIsLoading": "Jellyfin strežnik se zaganja. Poskusite ponovno kasneje.",
     "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
     "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
     "SubtitleDownloadFailureFromForItem": "Neuspešen prenos podnapisov iz {0} za {1}",
     "SubtitleDownloadFailureFromForItem": "Neuspešen prenos podnapisov iz {0} za {1}",
     "Sync": "Sinhroniziraj",
     "Sync": "Sinhroniziraj",
-    "System": "System",
+    "System": "Sistem",
     "TvShows": "TV serije",
     "TvShows": "TV serije",
-    "User": "User",
+    "User": "Uporabnik",
     "UserCreatedWithName": "Uporabnik {0} je bil ustvarjen",
     "UserCreatedWithName": "Uporabnik {0} je bil ustvarjen",
     "UserDeletedWithName": "Uporabnik {0} je bil izbrisan",
     "UserDeletedWithName": "Uporabnik {0} je bil izbrisan",
     "UserDownloadingItemWithValues": "{0} prenaša {1}",
     "UserDownloadingItemWithValues": "{0} prenaša {1}",
     "UserLockedOutWithName": "Uporabnik {0} je bil zaklenjen",
     "UserLockedOutWithName": "Uporabnik {0} je bil zaklenjen",
     "UserOfflineFromDevice": "{0} je prekinil povezavo z {1}",
     "UserOfflineFromDevice": "{0} je prekinil povezavo z {1}",
-    "UserOnlineFromDevice": "{0} je aktiven iz {1}",
+    "UserOnlineFromDevice": "{0} je aktiven na {1}",
     "UserPasswordChangedWithName": "Geslo za uporabnika {0} je bilo spremenjeno",
     "UserPasswordChangedWithName": "Geslo za uporabnika {0} je bilo spremenjeno",
     "UserPolicyUpdatedWithName": "Pravilnik uporabe je bil posodobljen za uporabnika {0}",
     "UserPolicyUpdatedWithName": "Pravilnik uporabe je bil posodobljen za uporabnika {0}",
     "UserStartedPlayingItemWithValues": "{0} predvaja {1} na {2}",
     "UserStartedPlayingItemWithValues": "{0} predvaja {1} na {2}",
     "UserStoppedPlayingItemWithValues": "{0} je nehal predvajati {1} na {2}",
     "UserStoppedPlayingItemWithValues": "{0} je nehal predvajati {1} na {2}",
     "ValueHasBeenAddedToLibrary": "{0} je bil dodan vaši knjižnici",
     "ValueHasBeenAddedToLibrary": "{0} je bil dodan vaši knjižnici",
-    "ValueSpecialEpisodeName": "Poseben - {0}",
+    "ValueSpecialEpisodeName": "Posebna - {0}",
     "VersionNumber": "Različica {0}",
     "VersionNumber": "Različica {0}",
     "TaskDownloadMissingSubtitles": "Prenesi manjkajoče podnapise",
     "TaskDownloadMissingSubtitles": "Prenesi manjkajoče podnapise",
     "TaskRefreshChannelsDescription": "Osveži podatke spletnih kanalov.",
     "TaskRefreshChannelsDescription": "Osveži podatke spletnih kanalov.",
@@ -102,7 +102,7 @@
     "TaskRefreshPeopleDescription": "Osveži metapodatke za igralce in režiserje v vaši knjižnici.",
     "TaskRefreshPeopleDescription": "Osveži metapodatke za igralce in režiserje v vaši knjižnici.",
     "TaskRefreshPeople": "Osveži osebe",
     "TaskRefreshPeople": "Osveži osebe",
     "TaskCleanLogsDescription": "Izbriše dnevniške datoteke starejše od {0} dni.",
     "TaskCleanLogsDescription": "Izbriše dnevniške datoteke starejše od {0} dni.",
-    "TaskCleanLogs": "Počisti mapo dnevnika",
+    "TaskCleanLogs": "Počisti mapo dnevnikov",
     "TaskRefreshLibraryDescription": "Preišče vašo knjižnico za nove datoteke in osveži metapodatke.",
     "TaskRefreshLibraryDescription": "Preišče vašo knjižnico za nove datoteke in osveži metapodatke.",
     "TaskRefreshLibrary": "Preišči knjižnico predstavnosti",
     "TaskRefreshLibrary": "Preišči knjižnico predstavnosti",
     "TaskRefreshChapterImagesDescription": "Ustvari sličice za poglavja videoposnetkov.",
     "TaskRefreshChapterImagesDescription": "Ustvari sličice za poglavja videoposnetkov.",

+ 5 - 3
Emby.Server.Implementations/Localization/Core/tr.json

@@ -8,7 +8,7 @@
     "CameraImageUploadedFrom": "{0} 'den yeni bir kamera resmi yüklendi",
     "CameraImageUploadedFrom": "{0} 'den yeni bir kamera resmi yüklendi",
     "Channels": "Kanallar",
     "Channels": "Kanallar",
     "ChapterNameValue": "Bölüm {0}",
     "ChapterNameValue": "Bölüm {0}",
-    "Collections": "Koleksiyonlar",
+    "Collections": "Koleksiyon",
     "DeviceOfflineWithName": "{0} bağlantısı kesildi",
     "DeviceOfflineWithName": "{0} bağlantısı kesildi",
     "DeviceOnlineWithName": "{0} bağlı",
     "DeviceOnlineWithName": "{0} bağlı",
     "FailedLoginAttemptWithUserName": "{0} adresinden giriş başarısız oldu",
     "FailedLoginAttemptWithUserName": "{0} adresinden giriş başarısız oldu",
@@ -23,7 +23,7 @@
     "HeaderFavoriteShows": "Favori Diziler",
     "HeaderFavoriteShows": "Favori Diziler",
     "HeaderFavoriteSongs": "Favori Şarkılar",
     "HeaderFavoriteSongs": "Favori Şarkılar",
     "HeaderLiveTV": "Canlı TV",
     "HeaderLiveTV": "Canlı TV",
-    "HeaderNextUp": "Sonraki hafta",
+    "HeaderNextUp": "Gelecek Hafta",
     "HeaderRecordingGroups": "Kayıt Grupları",
     "HeaderRecordingGroups": "Kayıt Grupları",
     "HomeVideos": "Ev videoları",
     "HomeVideos": "Ev videoları",
     "Inherit": "Devral",
     "Inherit": "Devral",
@@ -113,5 +113,7 @@
     "TaskRefreshLibrary": "Medya Kütüphanesini Tara",
     "TaskRefreshLibrary": "Medya Kütüphanesini Tara",
     "TaskRefreshChapterImagesDescription": "Sahnelere ayrılmış videolar için küçük resimler oluştur.",
     "TaskRefreshChapterImagesDescription": "Sahnelere ayrılmış videolar için küçük resimler oluştur.",
     "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar",
     "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar",
-    "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler."
+    "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.",
+    "TaskCleanActivityLog": "İşlem Günlüğünü Temizle",
+    "TaskCleanActivityLogDescription": "Belirtilen sureden daha eski etkinlik log kayıtları silindi."
 }
 }

+ 3 - 1
Emby.Server.Implementations/Localization/Core/vi.json

@@ -112,5 +112,7 @@
     "Books": "Sách",
     "Books": "Sách",
     "AuthenticationSucceededWithUserName": "{0} xác thực thành công",
     "AuthenticationSucceededWithUserName": "{0} xác thực thành công",
     "Application": "Ứng Dụng",
     "Application": "Ứng Dụng",
-    "AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}"
+    "AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}",
+    "TaskCleanActivityLogDescription": "Xóa các mục nhật ký hoạt động cũ hơn độ tuổi đã cài đặt.",
+    "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động"
 }
 }

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

@@ -113,5 +113,7 @@
     "TaskCleanCacheDescription": "删除系统不再需要的缓存文件。",
     "TaskCleanCacheDescription": "删除系统不再需要的缓存文件。",
     "TaskCleanCache": "清理缓存目录",
     "TaskCleanCache": "清理缓存目录",
     "TasksApplicationCategory": "应用程序",
     "TasksApplicationCategory": "应用程序",
-    "TasksMaintenanceCategory": "维护"
+    "TasksMaintenanceCategory": "维护",
+    "TaskCleanActivityLog": "清理程序日志",
+    "TaskCleanActivityLogDescription": "删除早于设置时间的活动日志条目。"
 }
 }

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

@@ -112,5 +112,7 @@
     "TaskRefreshChapterImagesDescription": "為有章節的影片建立縮圖。",
     "TaskRefreshChapterImagesDescription": "為有章節的影片建立縮圖。",
     "TasksChannelsCategory": "網路頻道",
     "TasksChannelsCategory": "網路頻道",
     "TasksApplicationCategory": "應用程式",
     "TasksApplicationCategory": "應用程式",
-    "TasksMaintenanceCategory": "維修"
+    "TasksMaintenanceCategory": "維護",
+    "TaskCleanActivityLogDescription": "刪除超過所設時間的活動紀錄。",
+    "TaskCleanActivityLog": "清除活動紀錄"
 }
 }

+ 5 - 5
Emby.Server.Implementations/Updates/InstallationManager.cs

@@ -16,7 +16,7 @@ using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Common.Updates;
 using MediaBrowser.Common.Updates;
-using MediaBrowser.Common.System;
+using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Events;
 using MediaBrowser.Controller.Events;
 using MediaBrowser.Controller.Events.Updates;
 using MediaBrowser.Controller.Events.Updates;
@@ -25,7 +25,6 @@ using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Updates;
 using MediaBrowser.Model.Updates;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
-using MediaBrowser.Model.System;
 
 
 namespace Emby.Server.Implementations.Updates
 namespace Emby.Server.Implementations.Updates
 {
 {
@@ -49,7 +48,7 @@ namespace Emby.Server.Implementations.Updates
         /// Gets the application host.
         /// Gets the application host.
         /// </summary>
         /// </summary>
         /// <value>The application host.</value>
         /// <value>The application host.</value>
-        private readonly IApplicationHost _applicationHost;
+        private readonly IServerApplicationHost _applicationHost;
 
 
         private readonly IZipClient _zipClient;
         private readonly IZipClient _zipClient;
 
 
@@ -67,7 +66,7 @@ namespace Emby.Server.Implementations.Updates
 
 
         public InstallationManager(
         public InstallationManager(
             ILogger<InstallationManager> logger,
             ILogger<InstallationManager> logger,
-            IApplicationHost appHost,
+            IServerApplicationHost appHost,
             IApplicationPaths appPaths,
             IApplicationPaths appPaths,
             IEventManager eventManager,
             IEventManager eventManager,
             IHttpClientFactory httpClientFactory,
             IHttpClientFactory httpClientFactory,
@@ -217,7 +216,8 @@ namespace Emby.Server.Implementations.Updates
 
 
         private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog)
         private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog)
         {
         {
-            foreach (var plugin in _applicationHost.Plugins)
+            var plugins = _applicationHost.GetLocalPlugins(_appPaths.PluginsPath);
+            foreach (var plugin in plugins)
             {
             {
                 var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin.Version);
                 var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin.Version);
                 var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version);
                 var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version);

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

@@ -50,6 +50,13 @@ namespace Jellyfin.Api.Auth
             bool localAccessOnly = false,
             bool localAccessOnly = false,
             bool requiredDownloadPermission = false)
             bool requiredDownloadPermission = false)
         {
         {
+            // ApiKey is currently global admin, always allow.
+            var isApiKey = ClaimHelpers.GetIsApiKey(claimsPrincipal);
+            if (isApiKey)
+            {
+                return true;
+            }
+
             // Ensure claim has userId.
             // Ensure claim has userId.
             var userId = ClaimHelpers.GetUserId(claimsPrincipal);
             var userId = ClaimHelpers.GetUserId(claimsPrincipal);
             if (!userId.HasValue)
             if (!userId.HasValue)

+ 7 - 8
Jellyfin.Api/Auth/CustomAuthenticationHandler.cs

@@ -1,10 +1,10 @@
 using System.Globalization;
 using System.Globalization;
-using System.Security.Authentication;
 using System.Security.Claims;
 using System.Security.Claims;
 using System.Text.Encodings.Web;
 using System.Text.Encodings.Web;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
@@ -43,24 +43,23 @@ namespace Jellyfin.Api.Auth
             try
             try
             {
             {
                 var authorizationInfo = _authService.Authenticate(Request);
                 var authorizationInfo = _authService.Authenticate(Request);
-                if (authorizationInfo == null)
+                var role = UserRoles.User;
+                if (authorizationInfo.IsApiKey || authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
                 {
                 {
-                    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"));
+                    role = UserRoles.Administrator;
                 }
                 }
 
 
                 var claims = new[]
                 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.Name, authorizationInfo.User?.Username ?? string.Empty),
+                    new Claim(ClaimTypes.Role, role),
                     new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
                     new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
                     new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId),
                     new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId),
                     new Claim(InternalClaimTypes.Device, authorizationInfo.Device),
                     new Claim(InternalClaimTypes.Device, authorizationInfo.Device),
                     new Claim(InternalClaimTypes.Client, authorizationInfo.Client),
                     new Claim(InternalClaimTypes.Client, authorizationInfo.Client),
                     new Claim(InternalClaimTypes.Version, authorizationInfo.Version),
                     new Claim(InternalClaimTypes.Version, authorizationInfo.Version),
                     new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
                     new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
+                    new Claim(InternalClaimTypes.IsApiKey, authorizationInfo.IsApiKey.ToString(CultureInfo.InvariantCulture))
                 };
                 };
 
 
                 var identity = new ClaimsIdentity(claims, Scheme.Name);
                 var identity = new ClaimsIdentity(claims, Scheme.Name);

+ 5 - 3
Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs

@@ -29,13 +29,15 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement)
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement)
         {
         {
             var validated = ValidateClaims(context.User);
             var validated = ValidateClaims(context.User);
-            if (!validated)
+            if (validated)
+            {
+                context.Succeed(requirement);
+            }
+            else
             {
             {
                 context.Fail();
                 context.Fail();
-                return Task.CompletedTask;
             }
             }
 
 
-            context.Succeed(requirement);
             return Task.CompletedTask;
             return Task.CompletedTask;
         }
         }
     }
     }

+ 5 - 3
Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs

@@ -29,13 +29,15 @@ namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement)
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement)
         {
         {
             var validated = ValidateClaims(context.User, ignoreSchedule: true);
             var validated = ValidateClaims(context.User, ignoreSchedule: true);
-            if (!validated)
+            if (validated)
+            {
+                context.Succeed(requirement);
+            }
+            else
             {
             {
                 context.Fail();
                 context.Fail();
-                return Task.CompletedTask;
             }
             }
 
 
-            context.Succeed(requirement);
             return Task.CompletedTask;
             return Task.CompletedTask;
         }
         }
     }
     }

+ 3 - 3
Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs

@@ -29,13 +29,13 @@ namespace Jellyfin.Api.Auth.LocalAccessPolicy
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement)
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement)
         {
         {
             var validated = ValidateClaims(context.User, localAccessOnly: true);
             var validated = ValidateClaims(context.User, localAccessOnly: true);
-            if (!validated)
+            if (validated)
             {
             {
-                context.Fail();
+                context.Succeed(requirement);
             }
             }
             else
             else
             {
             {
-                context.Succeed(requirement);
+                context.Fail();
             }
             }
 
 
             return Task.CompletedTask;
             return Task.CompletedTask;

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

@@ -34,5 +34,10 @@
         /// Token.
         /// Token.
         /// </summary>
         /// </summary>
         public const string Token = "Jellyfin-Token";
         public const string Token = "Jellyfin-Token";
+
+        /// <summary>
+        /// Is Api Key.
+        /// </summary>
+        public const string IsApiKey = "Jellyfin-IsApiKey";
     }
     }
 }
 }

+ 6 - 6
Jellyfin.Api/Controllers/ArtistsController.cs

@@ -146,9 +146,9 @@ namespace Jellyfin.Api.Controllers
                 NameLessThan = nameLessThan,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, ',', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
-                Genres = RequestHelpers.Split(genres, ',', true),
+                Tags = RequestHelpers.Split(tags, '|', true),
+                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
+                Genres = RequestHelpers.Split(genres, '|', true),
                 GenreIds = RequestHelpers.GetGuids(genreIds),
                 GenreIds = RequestHelpers.GetGuids(genreIds),
                 StudioIds = RequestHelpers.GetGuids(studioIds),
                 StudioIds = RequestHelpers.GetGuids(studioIds),
                 Person = person,
                 Person = person,
@@ -354,9 +354,9 @@ namespace Jellyfin.Api.Controllers
                 NameLessThan = nameLessThan,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, ',', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
-                Genres = RequestHelpers.Split(genres, ',', true),
+                Tags = RequestHelpers.Split(tags, '|', true),
+                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
+                Genres = RequestHelpers.Split(genres, '|', true),
                 GenreIds = RequestHelpers.GetGuids(genreIds),
                 GenreIds = RequestHelpers.GetGuids(genreIds),
                 StudioIds = RequestHelpers.GetGuids(studioIds),
                 StudioIds = RequestHelpers.GetGuids(studioIds),
                 Person = person,
                 Person = person,

+ 1 - 5
Jellyfin.Api/Controllers/DevicesController.cs

@@ -15,7 +15,7 @@ namespace Jellyfin.Api.Controllers
     /// <summary>
     /// <summary>
     /// Devices Controller.
     /// Devices Controller.
     /// </summary>
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [Authorize(Policy = Policies.RequiresElevation)]
     public class DevicesController : BaseJellyfinApiController
     public class DevicesController : BaseJellyfinApiController
     {
     {
         private readonly IDeviceManager _deviceManager;
         private readonly IDeviceManager _deviceManager;
@@ -46,7 +46,6 @@ namespace Jellyfin.Api.Controllers
         /// <response code="200">Devices retrieved.</response>
         /// <response code="200">Devices retrieved.</response>
         /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
         /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
         [HttpGet]
         [HttpGet]
-        [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
         public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
         {
         {
@@ -62,7 +61,6 @@ namespace Jellyfin.Api.Controllers
         /// <response code="404">Device not found.</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>
         /// <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")]
         [HttpGet("Info")]
-        [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, Required] string id)
         public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, Required] string id)
@@ -84,7 +82,6 @@ namespace Jellyfin.Api.Controllers
         /// <response code="404">Device not found.</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>
         /// <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")]
         [HttpGet("Options")]
-        [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, Required] string id)
         public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, Required] string id)
@@ -107,7 +104,6 @@ namespace Jellyfin.Api.Controllers
         /// <response code="404">Device not found.</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>
         /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
         [HttpPost("Options")]
         [HttpPost("Options")]
-        [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult UpdateDeviceOptions(
         public ActionResult UpdateDeviceOptions(

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

@@ -81,6 +81,9 @@ namespace Jellyfin.Api.Controllers
             dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
             dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
             dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
             dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
 
 
+            // This will essentially be a noop if no changes have been made, but new prefs must be saved at least.
+            _displayPreferencesManager.SaveChanges();
+
             return dto;
             return dto;
         }
         }
 
 

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

@@ -77,6 +77,7 @@ namespace Jellyfin.Api.Controllers
         /// Gets Dlna media receiver registrar xml.
         /// Gets Dlna media receiver registrar xml.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Dlna media receiver registrar xml returned.</response>
         /// <returns>Dlna media receiver registrar xml.</returns>
         /// <returns>Dlna media receiver registrar xml.</returns>
         [HttpGet("{serverId}/MediaReceiverRegistrar")]
         [HttpGet("{serverId}/MediaReceiverRegistrar")]
         [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
         [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
@@ -94,6 +95,7 @@ namespace Jellyfin.Api.Controllers
         /// Gets Dlna media receiver registrar xml.
         /// Gets Dlna media receiver registrar xml.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Dlna media receiver registrar xml returned.</response>
         /// <returns>Dlna media receiver registrar xml.</returns>
         /// <returns>Dlna media receiver registrar xml.</returns>
         [HttpGet("{serverId}/ConnectionManager")]
         [HttpGet("{serverId}/ConnectionManager")]
         [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
         [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
@@ -111,8 +113,12 @@ namespace Jellyfin.Api.Controllers
         /// Process a content directory control request.
         /// Process a content directory control request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Control response.</returns>
         /// <returns>Control response.</returns>
         [HttpPost("{serverId}/ContentDirectory/Control")]
         [HttpPost("{serverId}/ContentDirectory/Control")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId)
         public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId)
         {
         {
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
@@ -122,8 +128,12 @@ namespace Jellyfin.Api.Controllers
         /// Process a connection manager control request.
         /// Process a connection manager control request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Control response.</returns>
         /// <returns>Control response.</returns>
         [HttpPost("{serverId}/ConnectionManager/Control")]
         [HttpPost("{serverId}/ConnectionManager/Control")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId)
         public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId)
         {
         {
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
@@ -133,8 +143,12 @@ namespace Jellyfin.Api.Controllers
         /// Process a media receiver registrar control request.
         /// Process a media receiver registrar control request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Control response.</returns>
         /// <returns>Control response.</returns>
         [HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
         [HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId)
         public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId)
         {
         {
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
@@ -144,11 +158,15 @@ namespace Jellyfin.Api.Controllers
         /// Processes an event subscription request.
         /// Processes an event subscription request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Event subscription response.</returns>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
         public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
         {
         {
             return ProcessEventRequest(_mediaReceiverRegistrar);
             return ProcessEventRequest(_mediaReceiverRegistrar);
@@ -158,11 +176,15 @@ namespace Jellyfin.Api.Controllers
         /// Processes an event subscription request.
         /// Processes an event subscription request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Event subscription response.</returns>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/ContentDirectory/Events")]
         [HttpSubscribe("{serverId}/ContentDirectory/Events")]
         [HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
         [HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
         public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
         {
         {
             return ProcessEventRequest(_contentDirectory);
             return ProcessEventRequest(_contentDirectory);
@@ -172,11 +194,15 @@ namespace Jellyfin.Api.Controllers
         /// Processes an event subscription request.
         /// Processes an event subscription request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Event subscription response.</returns>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/ConnectionManager/Events")]
         [HttpSubscribe("{serverId}/ConnectionManager/Events")]
         [HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
         [HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
         public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
         {
         {
             return ProcessEventRequest(_connectionManager);
             return ProcessEventRequest(_connectionManager);

+ 9 - 3
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -295,6 +295,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</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="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
         /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
         /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
         /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
         /// <param name="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="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="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
@@ -351,6 +352,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? breakOnNonKeyFrames,
             [FromQuery] bool? breakOnNonKeyFrames,
             [FromQuery] int? audioSampleRate,
             [FromQuery] int? audioSampleRate,
             [FromQuery] int? maxAudioBitDepth,
             [FromQuery] int? maxAudioBitDepth,
+            [FromQuery] int? maxStreamingBitrate,
             [FromQuery] int? audioBitRate,
             [FromQuery] int? audioBitRate,
             [FromQuery] int? audioChannels,
             [FromQuery] int? audioChannels,
             [FromQuery] int? maxAudioChannels,
             [FromQuery] int? maxAudioChannels,
@@ -403,7 +405,7 @@ namespace Jellyfin.Api.Controllers
                 BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
                 BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
                 AudioSampleRate = audioSampleRate,
                 AudioSampleRate = audioSampleRate,
                 MaxAudioChannels = maxAudioChannels,
                 MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
+                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
                 MaxAudioBitDepth = maxAudioBitDepth,
                 MaxAudioBitDepth = maxAudioBitDepth,
                 AudioChannels = audioChannels,
                 AudioChannels = audioChannels,
                 Profile = profile,
                 Profile = profile,
@@ -623,6 +625,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</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="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
         /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
         /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
         /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
         /// <param name="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="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="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
@@ -677,6 +680,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? breakOnNonKeyFrames,
             [FromQuery] bool? breakOnNonKeyFrames,
             [FromQuery] int? audioSampleRate,
             [FromQuery] int? audioSampleRate,
             [FromQuery] int? maxAudioBitDepth,
             [FromQuery] int? maxAudioBitDepth,
+            [FromQuery] int? maxStreamingBitrate,
             [FromQuery] int? audioBitRate,
             [FromQuery] int? audioBitRate,
             [FromQuery] int? audioChannels,
             [FromQuery] int? audioChannels,
             [FromQuery] int? maxAudioChannels,
             [FromQuery] int? maxAudioChannels,
@@ -729,7 +733,7 @@ namespace Jellyfin.Api.Controllers
                 BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
                 BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
                 AudioSampleRate = audioSampleRate,
                 AudioSampleRate = audioSampleRate,
                 MaxAudioChannels = maxAudioChannels,
                 MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
+                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
                 MaxAudioBitDepth = maxAudioBitDepth,
                 MaxAudioBitDepth = maxAudioBitDepth,
                 AudioChannels = audioChannels,
                 AudioChannels = audioChannels,
                 Profile = profile,
                 Profile = profile,
@@ -959,6 +963,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</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="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
         /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
         /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
         /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
         /// <param name="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="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="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
@@ -1017,6 +1022,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? breakOnNonKeyFrames,
             [FromQuery] bool? breakOnNonKeyFrames,
             [FromQuery] int? audioSampleRate,
             [FromQuery] int? audioSampleRate,
             [FromQuery] int? maxAudioBitDepth,
             [FromQuery] int? maxAudioBitDepth,
+            [FromQuery] int? maxStreamingBitrate,
             [FromQuery] int? audioBitRate,
             [FromQuery] int? audioBitRate,
             [FromQuery] int? audioChannels,
             [FromQuery] int? audioChannels,
             [FromQuery] int? maxAudioChannels,
             [FromQuery] int? maxAudioChannels,
@@ -1069,7 +1075,7 @@ namespace Jellyfin.Api.Controllers
                 BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
                 BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
                 AudioSampleRate = audioSampleRate,
                 AudioSampleRate = audioSampleRate,
                 MaxAudioChannels = maxAudioChannels,
                 MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
+                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
                 MaxAudioBitDepth = maxAudioBitDepth,
                 MaxAudioBitDepth = maxAudioBitDepth,
                 AudioChannels = audioChannels,
                 AudioChannels = audioChannels,
                 Profile = profile,
                 Profile = profile,

+ 12 - 129
Jellyfin.Api/Controllers/GenresController.cs

@@ -1,11 +1,9 @@
 using System;
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
-using System.Globalization;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
-using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
@@ -49,7 +47,6 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// <summary>
         /// Gets all genres from a given item, folder, or the entire library.
         /// Gets all genres from a given item, folder, or the entire library.
         /// </summary>
         /// </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="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="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">The search term.</param>
         /// <param name="searchTerm">The search term.</param>
@@ -57,22 +54,9 @@ namespace Jellyfin.Api.Controllers
         /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
         /// <param name="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="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.</param>
         /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</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="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="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="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="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="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -84,7 +68,6 @@ namespace Jellyfin.Api.Controllers
         [HttpGet]
         [HttpGet]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetGenres(
         public ActionResult<QueryResult<BaseItemDto>> GetGenres(
-            [FromQuery] double? minCommunityRating,
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
@@ -92,22 +75,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] ItemFields[] fields,
             [FromQuery] ItemFields[] fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [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] int? imageTypeLimit,
             [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
             [FromQuery] string? nameStartsWith,
@@ -117,42 +87,22 @@ namespace Jellyfin.Api.Controllers
         {
         {
             var dtoOptions = new DtoOptions { Fields = fields }
             var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+                .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
 
 
-            User? user = null;
-            BaseItem parentItem;
+            User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null;
 
 
-            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 parentItem = _libraryManager.GetParentItem(parentId, userId);
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
                 ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
                 ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
                 IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
                 IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
-                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
                 StartIndex = startIndex,
                 StartIndex = startIndex,
                 Limit = limit,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
                 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,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
                 SearchTerm = searchTerm,
                 EnableTotalRecordCount = enableTotalRecordCount
                 EnableTotalRecordCount = enableTotalRecordCount
@@ -170,87 +120,20 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
             }
             }
 
 
-            // Studios
-            if (!string.IsNullOrEmpty(studios))
+            QueryResult<(BaseItem, ItemCounts)> result;
+            if (parentItem is ICollectionFolder parentCollectionFolder
+                && (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal)
+                || string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal)))
             {
             {
-                query.StudioIds = studios.Split('|')
-                    .Select(i =>
-                    {
-                        try
-                        {
-                            return _libraryManager.GetStudio(i);
-                        }
-                        catch
-                        {
-                            return null;
-                        }
-                    }).Where(i => i != null)
-                    .Select(i => i!.Id)
-                    .ToArray();
+                result = _libraryManager.GetMusicGenres(query);
             }
             }
-
-            foreach (var filter in filters)
+            else
             {
             {
-                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;
-                }
+                result = _libraryManager.GetGenres(query);
             }
             }
 
 
-            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
-            };
+            var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
+            return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
         }
 
 
         /// <summary>
         /// <summary>

+ 2 - 2
Jellyfin.Api/Controllers/InstantMixController.cs

@@ -309,9 +309,9 @@ namespace Jellyfin.Api.Controllers
                 TotalRecordCount = list.Count
                 TotalRecordCount = list.Count
             };
             };
 
 
-            if (limit.HasValue)
+            if (limit.HasValue && limit < list.Count)
             {
             {
-                list = list.Take(limit.Value).ToList();
+                list = list.GetRange(0, limit.Value);
             }
             }
 
 
             var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);
             var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);

+ 4 - 7
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -590,7 +590,7 @@ namespace Jellyfin.Api.Controllers
                 IsKids = isKids,
                 IsKids = isKids,
                 IsSports = isSports,
                 IsSports = isSports,
                 SeriesTimerId = seriesTimerId,
                 SeriesTimerId = seriesTimerId,
-                Genres = RequestHelpers.Split(genres, ',', true),
+                Genres = RequestHelpers.Split(genres, '|', true),
                 GenreIds = RequestHelpers.GetGuids(genreIds)
                 GenreIds = RequestHelpers.GetGuids(genreIds)
             };
             };
 
 
@@ -645,7 +645,7 @@ namespace Jellyfin.Api.Controllers
                 IsKids = body.IsKids,
                 IsKids = body.IsKids,
                 IsSports = body.IsSports,
                 IsSports = body.IsSports,
                 SeriesTimerId = body.SeriesTimerId,
                 SeriesTimerId = body.SeriesTimerId,
-                Genres = RequestHelpers.Split(body.Genres, ',', true),
+                Genres = RequestHelpers.Split(body.Genres, '|', true),
                 GenreIds = RequestHelpers.GetGuids(body.GenreIds)
                 GenreIds = RequestHelpers.GetGuids(body.GenreIds)
             };
             };
 
 
@@ -1215,11 +1215,8 @@ namespace Jellyfin.Api.Controllers
                 return NotFound();
                 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));
+            var liveStream = new ProgressiveFileStream(liveStreamInfo.GetFilePath(), null, _transcodingJobHelper);
+            return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container));
         }
         }
 
 
         private void AssertUserCanManageLiveTv()
         private void AssertUserCanManageLiveTv()

+ 2 - 2
Jellyfin.Api/Controllers/MediaInfoController.cs

@@ -104,7 +104,7 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo(
         public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo(
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] Guid itemId,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
-            [FromQuery] long? maxStreamingBitrate,
+            [FromQuery] int? maxStreamingBitrate,
             [FromQuery] long? startTimeTicks,
             [FromQuery] long? startTimeTicks,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? subtitleStreamIndex,
             [FromQuery] int? subtitleStreamIndex,
@@ -234,7 +234,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? openToken,
             [FromQuery] string? openToken,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? playSessionId,
             [FromQuery] string? playSessionId,
-            [FromQuery] long? maxStreamingBitrate,
+            [FromQuery] int? maxStreamingBitrate,
             [FromQuery] long? startTimeTicks,
             [FromQuery] long? startTimeTicks,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? subtitleStreamIndex,
             [FromQuery] int? subtitleStreamIndex,

+ 6 - 132
Jellyfin.Api/Controllers/MusicGenresController.cs

@@ -1,11 +1,9 @@
 using System;
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
-using System.Globalization;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
-using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
@@ -49,7 +47,6 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// <summary>
         /// Gets all music genres from a given item, folder, or the entire library.
         /// Gets all music genres from a given item, folder, or the entire library.
         /// </summary>
         /// </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="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="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">The search term.</param>
         /// <param name="searchTerm">The search term.</param>
@@ -57,22 +54,9 @@ namespace Jellyfin.Api.Controllers
         /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
         /// <param name="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="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.</param>
         /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</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="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="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="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="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="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -82,8 +66,8 @@ namespace Jellyfin.Api.Controllers
         /// <response code="200">Music genres returned.</response>
         /// <response code="200">Music genres returned.</response>
         /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns>
         /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns>
         [HttpGet]
         [HttpGet]
+        [Obsolete("Use GetGenres instead")]
         public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres(
         public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres(
-            [FromQuery] double? minCommunityRating,
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
@@ -91,22 +75,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] ItemFields[] fields,
             [FromQuery] ItemFields[] fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [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] int? imageTypeLimit,
             [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
             [FromQuery] string? nameStartsWith,
@@ -116,42 +87,22 @@ namespace Jellyfin.Api.Controllers
         {
         {
             var dtoOptions = new DtoOptions { Fields = fields }
             var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+                .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
 
 
-            User? user = null;
-            BaseItem parentItem;
+            User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null;
 
 
-            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 parentItem = _libraryManager.GetParentItem(parentId, userId);
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
                 ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
                 ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
                 IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
                 IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
-                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
                 StartIndex = startIndex,
                 StartIndex = startIndex,
                 Limit = limit,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
                 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,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
                 SearchTerm = searchTerm,
                 EnableTotalRecordCount = enableTotalRecordCount
                 EnableTotalRecordCount = enableTotalRecordCount
@@ -169,87 +120,10 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
             }
             }
 
 
-            // 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 filters)
-            {
-                switch (filter)
-                {
-                    case ItemFilter.Dislikes:
-                        query.IsLiked = false;
-                        break;
-                    case ItemFilter.IsFavorite:
-                        query.IsFavorite = true;
-                        break;
-                    case ItemFilter.IsFavoriteOrLikes:
-                        query.IsFavoriteOrLiked = true;
-                        break;
-                    case ItemFilter.IsFolder:
-                        query.IsFolder = true;
-                        break;
-                    case ItemFilter.IsNotFolder:
-                        query.IsFolder = false;
-                        break;
-                    case ItemFilter.IsPlayed:
-                        query.IsPlayed = true;
-                        break;
-                    case ItemFilter.IsResumable:
-                        query.IsResumable = true;
-                        break;
-                    case ItemFilter.IsUnplayed:
-                        query.IsPlayed = false;
-                        break;
-                    case ItemFilter.Likes:
-                        query.IsLiked = true;
-                        break;
-                }
-            }
-
             var result = _libraryManager.GetMusicGenres(query);
             var result = _libraryManager.GetMusicGenres(query);
 
 
-            var dtos = result.Items.Select(i =>
-            {
-                var (baseItem, counts) = i;
-                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
-                if (!string.IsNullOrWhiteSpace(includeItemTypes))
-                {
-                    dto.ChildCount = counts.ItemCount;
-                    dto.ProgramCount = counts.ProgramCount;
-                    dto.SeriesCount = counts.SeriesCount;
-                    dto.EpisodeCount = counts.EpisodeCount;
-                    dto.MovieCount = counts.MovieCount;
-                    dto.TrailerCount = counts.TrailerCount;
-                    dto.AlbumCount = counts.AlbumCount;
-                    dto.SongCount = counts.SongCount;
-                    dto.ArtistCount = counts.ArtistCount;
-                }
-
-                return dto;
-            });
-
-            return new QueryResult<BaseItemDto>
-            {
-                Items = dtos.ToArray(),
-                TotalRecordCount = result.TotalRecordCount
-            };
+            var shouldIncludeItemTypes = !string.IsNullOrWhiteSpace(includeItemTypes);
+            return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
         }
 
 
         /// <summary>
         /// <summary>

+ 23 - 162
Jellyfin.Api/Controllers/PersonsController.cs

@@ -1,6 +1,5 @@
 using System;
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
-using System.Globalization;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
@@ -28,6 +27,7 @@ namespace Jellyfin.Api.Controllers
         private readonly ILibraryManager _libraryManager;
         private readonly ILibraryManager _libraryManager;
         private readonly IDtoService _dtoService;
         private readonly IDtoService _dtoService;
         private readonly IUserManager _userManager;
         private readonly IUserManager _userManager;
+        private readonly IUserDataManager _userDataManager;
 
 
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="PersonsController"/> class.
         /// Initializes a new instance of the <see cref="PersonsController"/> class.
@@ -35,220 +35,81 @@ namespace Jellyfin.Api.Controllers
         /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> 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="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
         /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
         /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
         public PersonsController(
         public PersonsController(
             ILibraryManager libraryManager,
             ILibraryManager libraryManager,
             IDtoService dtoService,
             IDtoService dtoService,
-            IUserManager userManager)
+            IUserManager userManager,
+            IUserDataManager userDataManager)
         {
         {
             _libraryManager = libraryManager;
             _libraryManager = libraryManager;
             _dtoService = dtoService;
             _dtoService = dtoService;
             _userManager = userManager;
             _userManager = userManager;
+            _userDataManager = userDataManager;
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Gets all persons from a given item, folder, or the entire library.
+        /// Gets all persons.
         /// </summary>
         /// </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="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">The search term.</param>
         /// <param name="searchTerm">The search term.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
         /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
         /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</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="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param>
         /// <param name="enableUserData">Optional, include user data.</param>
         /// <param name="enableUserData">Optional, include user data.</param>
         /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</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="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="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param>
+        /// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param>
+        /// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param>
         /// <param name="userId">User id.</param>
         /// <param name="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="enableImages">Optional, include image information in output.</param>
-        /// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
         /// <response code="200">Persons returned.</response>
         /// <response code="200">Persons returned.</response>
         /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns>
         /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns>
         [HttpGet]
         [HttpGet]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetPersons(
         public ActionResult<QueryResult<BaseItemDto>> GetPersons(
-            [FromQuery] double? minCommunityRating,
-            [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
-            [FromQuery] string? parentId,
             [FromQuery] ItemFields[] fields,
             [FromQuery] ItemFields[] fields,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? includeItemTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [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] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
+            [FromQuery] string? excludePersonTypes,
             [FromQuery] string? personTypes,
             [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [FromQuery] string? appearsInItemId,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery] bool? enableImages = true,
-            [FromQuery] bool enableTotalRecordCount = true)
+            [FromQuery] bool? enableImages = true)
         {
         {
             var dtoOptions = new DtoOptions { Fields = fields }
             var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 
             User? user = null;
             User? user = null;
-            BaseItem parentItem;
 
 
             if (userId.HasValue && !userId.Equals(Guid.Empty))
             if (userId.HasValue && !userId.Equals(Guid.Empty))
             {
             {
                 user = _userManager.GetUserById(userId.Value);
                 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)
+            var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite);
+            var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery
             {
             {
-                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),
                 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 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;
+                ExcludePersonTypes = RequestHelpers.Split(excludePersonTypes, ',', true),
+                NameContains = searchTerm,
+                User = user,
+                IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite,
+                AppearsInItemId = string.IsNullOrEmpty(appearsInItemId) ? Guid.Empty : Guid.Parse(appearsInItemId),
+                Limit = limit ?? 0
             });
             });
 
 
             return new QueryResult<BaseItemDto>
             return new QueryResult<BaseItemDto>
             {
             {
-                Items = dtos.ToArray(),
-                TotalRecordCount = result.TotalRecordCount
+                Items = peopleItems.Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)).ToArray(),
+                TotalRecordCount = peopleItems.Count
             };
             };
         }
         }
 
 

+ 5 - 129
Jellyfin.Api/Controllers/StudiosController.cs

@@ -1,10 +1,8 @@
 using System;
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
-using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
-using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
@@ -47,7 +45,6 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// <summary>
         /// Gets all studios from a given item, folder, or the entire library.
         /// Gets all studios from a given item, folder, or the entire library.
         /// </summary>
         /// </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="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="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">Optional. Search term.</param>
         /// <param name="searchTerm">Optional. Search term.</param>
@@ -55,22 +52,10 @@ namespace Jellyfin.Api.Controllers
         /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
         /// <param name="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="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
         /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
         /// <param name="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="enableUserData">Optional, include user data.</param>
         /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</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="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="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="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="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -82,7 +67,6 @@ namespace Jellyfin.Api.Controllers
         [HttpGet]
         [HttpGet]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetStudios(
         public ActionResult<QueryResult<BaseItemDto>> GetStudios(
-            [FromQuery] double? minCommunityRating,
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
@@ -90,22 +74,10 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] ItemFields[] fields,
             [FromQuery] ItemFields[] fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [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] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
             [FromQuery] string? nameStartsWith,
@@ -117,44 +89,23 @@ namespace Jellyfin.Api.Controllers
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 
-            User? user = null;
-            BaseItem parentItem;
+            User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null;
 
 
-            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 parentItem = _libraryManager.GetParentItem(parentId, userId);
 
 
             var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
             var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
             var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
             var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
-            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
                 ExcludeItemTypes = excludeItemTypesArr,
                 ExcludeItemTypes = excludeItemTypesArr,
                 IncludeItemTypes = includeItemTypesArr,
                 IncludeItemTypes = includeItemTypesArr,
-                MediaTypes = mediaTypesArr,
                 StartIndex = startIndex,
                 StartIndex = startIndex,
                 Limit = limit,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
                 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,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
                 SearchTerm = searchTerm,
                 EnableTotalRecordCount = enableTotalRecordCount
                 EnableTotalRecordCount = enableTotalRecordCount
@@ -172,84 +123,9 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
             }
             }
 
 
-            // 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 filters)
-            {
-                switch (filter)
-                {
-                    case ItemFilter.Dislikes:
-                        query.IsLiked = false;
-                        break;
-                    case ItemFilter.IsFavorite:
-                        query.IsFavorite = true;
-                        break;
-                    case ItemFilter.IsFavoriteOrLikes:
-                        query.IsFavoriteOrLiked = true;
-                        break;
-                    case ItemFilter.IsFolder:
-                        query.IsFolder = true;
-                        break;
-                    case ItemFilter.IsNotFolder:
-                        query.IsFolder = false;
-                        break;
-                    case ItemFilter.IsPlayed:
-                        query.IsPlayed = true;
-                        break;
-                    case ItemFilter.IsResumable:
-                        query.IsResumable = true;
-                        break;
-                    case ItemFilter.IsUnplayed:
-                        query.IsPlayed = false;
-                        break;
-                    case ItemFilter.Likes:
-                        query.IsLiked = true;
-                        break;
-                }
-            }
-
-            var result = new QueryResult<(BaseItem, ItemCounts)>();
-            var dtos = result.Items.Select(i =>
-            {
-                var (baseItem, itemCounts) = i;
-                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
-                if (!string.IsNullOrWhiteSpace(includeItemTypes))
-                {
-                    dto.ChildCount = itemCounts.ItemCount;
-                    dto.ProgramCount = itemCounts.ProgramCount;
-                    dto.SeriesCount = itemCounts.SeriesCount;
-                    dto.EpisodeCount = itemCounts.EpisodeCount;
-                    dto.MovieCount = itemCounts.MovieCount;
-                    dto.TrailerCount = itemCounts.TrailerCount;
-                    dto.AlbumCount = itemCounts.AlbumCount;
-                    dto.SongCount = itemCounts.SongCount;
-                    dto.ArtistCount = itemCounts.ArtistCount;
-                }
-
-                return dto;
-            });
-
-            return new QueryResult<BaseItemDto>
-            {
-                Items = dtos.ToArray(),
-                TotalRecordCount = result.TotalRecordCount
-            };
+            var result = _libraryManager.GetStudios(query);
+            var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
+            return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
         }
 
 
         /// <summary>
         /// <summary>

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

@@ -11,6 +11,9 @@ using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.SubtitleDtos;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.MediaEncoding;
@@ -21,6 +24,7 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Providers;
 using MediaBrowser.Model.Providers;
+using MediaBrowser.Model.Subtitles;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc;
@@ -34,6 +38,7 @@ namespace Jellyfin.Api.Controllers
     [Route("")]
     [Route("")]
     public class SubtitleController : BaseJellyfinApiController
     public class SubtitleController : BaseJellyfinApiController
     {
     {
+        private readonly IServerConfigurationManager _serverConfigurationManager;
         private readonly ILibraryManager _libraryManager;
         private readonly ILibraryManager _libraryManager;
         private readonly ISubtitleManager _subtitleManager;
         private readonly ISubtitleManager _subtitleManager;
         private readonly ISubtitleEncoder _subtitleEncoder;
         private readonly ISubtitleEncoder _subtitleEncoder;
@@ -46,6 +51,7 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="SubtitleController"/> class.
         /// Initializes a new instance of the <see cref="SubtitleController"/> class.
         /// </summary>
         /// </summary>
+        /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
         /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
         /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
         /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param>
         /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param>
         /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param>
         /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param>
@@ -55,6 +61,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
         /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
         /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
         /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
         public SubtitleController(
         public SubtitleController(
+            IServerConfigurationManager serverConfigurationManager,
             ILibraryManager libraryManager,
             ILibraryManager libraryManager,
             ISubtitleManager subtitleManager,
             ISubtitleManager subtitleManager,
             ISubtitleEncoder subtitleEncoder,
             ISubtitleEncoder subtitleEncoder,
@@ -64,6 +71,7 @@ namespace Jellyfin.Api.Controllers
             IAuthorizationContext authContext,
             IAuthorizationContext authContext,
             ILogger<SubtitleController> logger)
             ILogger<SubtitleController> logger)
         {
         {
+            _serverConfigurationManager = serverConfigurationManager;
             _libraryManager = libraryManager;
             _libraryManager = libraryManager;
             _subtitleManager = subtitleManager;
             _subtitleManager = subtitleManager;
             _subtitleEncoder = subtitleEncoder;
             _subtitleEncoder = subtitleEncoder;
@@ -319,6 +327,33 @@ namespace Jellyfin.Api.Controllers
             return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
             return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
         }
         }
 
 
+        /// <summary>
+        /// Upload an external subtitle file.
+        /// </summary>
+        /// <param name="itemId">The item the subtitle belongs to.</param>
+        /// <param name="body">The request body.</param>
+        /// <response code="204">Subtitle uploaded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("Videos/{itemId}/Subtitles")]
+        public async Task<ActionResult> UploadSubtitle(
+            [FromRoute, Required] Guid itemId,
+            [FromBody, Required] UploadSubtitleDto body)
+        {
+            var video = (Video)_libraryManager.GetItemById(itemId);
+            var data = Convert.FromBase64String(body.Data);
+            await using var memoryStream = new MemoryStream(data);
+            await _subtitleManager.UploadSubtitle(
+                video,
+                new SubtitleResponse
+                {
+                    Format = body.Format,
+                    Language = body.Language,
+                    IsForced = body.IsForced,
+                    Stream = memoryStream
+                }).ConfigureAwait(false);
+            return NoContent();
+        }
+
         /// <summary>
         /// <summary>
         /// Encodes a subtitle in the specified format.
         /// Encodes a subtitle in the specified format.
         /// </summary>
         /// </summary>
@@ -351,5 +386,95 @@ namespace Jellyfin.Api.Controllers
                 copyTimestamps,
                 copyTimestamps,
                 CancellationToken.None);
                 CancellationToken.None);
         }
         }
+
+        /// <summary>
+        /// Gets a list of available fallback font files.
+        /// </summary>
+        /// <response code="200">Information retrieved.</response>
+        /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns>
+        [HttpGet("FallbackFont/Fonts")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public IEnumerable<FontFile> GetFallbackFontList()
+        {
+            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+            var fallbackFontPath = encodingOptions.FallbackFontPath;
+
+            if (!string.IsNullOrEmpty(fallbackFontPath))
+            {
+                var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false);
+                var fontFiles = files
+                    .Select(i => new FontFile
+                    {
+                        Name = i.Name,
+                        Size = i.Length,
+                        DateCreated = _fileSystem.GetCreationTimeUtc(i),
+                        DateModified = _fileSystem.GetLastWriteTimeUtc(i)
+                    })
+                    .OrderBy(i => i.Size)
+                    .ThenBy(i => i.Name)
+                    .ThenByDescending(i => i.DateModified)
+                    .ThenByDescending(i => i.DateCreated);
+                // max total size 20M
+                const int MaxSize = 20971520;
+                var sizeCounter = 0L;
+                foreach (var fontFile in fontFiles)
+                {
+                    sizeCounter += fontFile.Size;
+                    if (sizeCounter >= MaxSize)
+                    {
+                        _logger.LogWarning("Some fonts will not be sent due to size limitations");
+                        yield break;
+                    }
+
+                    yield return fontFile;
+                }
+            }
+            else
+            {
+                _logger.LogWarning("The path of fallback font folder has not been set");
+                encodingOptions.EnableFallbackFont = false;
+            }
+        }
+
+        /// <summary>
+        /// Gets a fallback font file.
+        /// </summary>
+        /// <param name="name">The name of the fallback font file to get.</param>
+        /// <response code="200">Fallback font file retrieved.</response>
+        /// <returns>The fallback font file.</returns>
+        [HttpGet("FallbackFont/Fonts/{name}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult GetFallbackFont([FromRoute, Required] string name)
+        {
+            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+            var fallbackFontPath = encodingOptions.FallbackFontPath;
+
+            if (!string.IsNullOrEmpty(fallbackFontPath))
+            {
+                var fontFile = _fileSystem.GetFiles(fallbackFontPath)
+                    .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
+                var fileSize = fontFile?.Length;
+
+                if (fontFile != null && fileSize != null && fileSize > 0)
+                {
+                    _logger.LogDebug("Fallback font size is {fileSize} Bytes", fileSize);
+                    return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName));
+                }
+                else
+                {
+                    _logger.LogWarning("The selected font is null or empty");
+                }
+            }
+            else
+            {
+                _logger.LogWarning("The path of fallback font folder has not been set");
+                encodingOptions.EnableFallbackFont = false;
+            }
+
+            // returning HTTP 204 will break the SubtitlesOctopus
+            return Ok();
+        }
     }
     }
 }
 }

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

@@ -1,6 +1,7 @@
 using System;
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;
+using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
@@ -9,6 +10,7 @@ using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc;
 
 
@@ -18,6 +20,7 @@ namespace Jellyfin.Api.Controllers
     /// The suggestions controller.
     /// The suggestions controller.
     /// </summary>
     /// </summary>
     [Route("")]
     [Route("")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
     public class SuggestionsController : BaseJellyfinApiController
     public class SuggestionsController : BaseJellyfinApiController
     {
     {
         private readonly IDtoService _dtoService;
         private readonly IDtoService _dtoService;

+ 18 - 14
Jellyfin.Api/Controllers/UniversalAudioController.cs

@@ -76,6 +76,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param>
         /// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param>
         /// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param>
         /// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param>
         /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
         /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
+        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
         /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
         /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
         /// <param name="transcodingContainer">Optional. The container to transcode to.</param>
         /// <param name="transcodingContainer">Optional. The container to transcode to.</param>
         /// <param name="transcodingProtocol">Optional. The transcoding protocol.</param>
         /// <param name="transcodingProtocol">Optional. The transcoding protocol.</param>
@@ -88,23 +89,22 @@ namespace Jellyfin.Api.Controllers
         /// <response code="302">Redirected to remote audio stream.</response>
         /// <response code="302">Redirected to remote audio stream.</response>
         /// <returns>A <see cref="Task"/> containing the audio file.</returns>
         /// <returns>A <see cref="Task"/> containing the audio file.</returns>
         [HttpGet("Audio/{itemId}/universal")]
         [HttpGet("Audio/{itemId}/universal")]
-        [HttpGet("Audio/{itemId}/universal.{container}", Name = "GetUniversalAudioStream_2")]
         [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")]
         [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")]
-        [HttpHead("Audio/{itemId}/universal.{container}", Name = "HeadUniversalAudioStream_2")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status302Found)]
         [ProducesResponseType(StatusCodes.Status302Found)]
         [ProducesAudioFile]
         [ProducesAudioFile]
         public async Task<ActionResult> GetUniversalAudioStream(
         public async Task<ActionResult> GetUniversalAudioStream(
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] Guid itemId,
-            [FromRoute] string? container,
+            [FromQuery] string? container,
             [FromQuery] string? mediaSourceId,
             [FromQuery] string? mediaSourceId,
             [FromQuery] string? deviceId,
             [FromQuery] string? deviceId,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? audioCodec,
             [FromQuery] string? audioCodec,
             [FromQuery] int? maxAudioChannels,
             [FromQuery] int? maxAudioChannels,
             [FromQuery] int? transcodingAudioChannels,
             [FromQuery] int? transcodingAudioChannels,
-            [FromQuery] long? maxStreamingBitrate,
+            [FromQuery] int? maxStreamingBitrate,
+            [FromQuery] int? audioBitRate,
             [FromQuery] long? startTimeTicks,
             [FromQuery] long? startTimeTicks,
             [FromQuery] string? transcodingContainer,
             [FromQuery] string? transcodingContainer,
             [FromQuery] string? transcodingProtocol,
             [FromQuery] string? transcodingProtocol,
@@ -212,7 +212,7 @@ namespace Jellyfin.Api.Controllers
                     AudioSampleRate = maxAudioSampleRate,
                     AudioSampleRate = maxAudioSampleRate,
                     MaxAudioChannels = maxAudioChannels,
                     MaxAudioChannels = maxAudioChannels,
                     MaxAudioBitDepth = maxAudioBitDepth,
                     MaxAudioBitDepth = maxAudioBitDepth,
-                    AudioChannels = isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)),
+                    AudioBitRate = audioBitRate ?? maxStreamingBitrate,
                     StartTimeTicks = startTimeTicks,
                     StartTimeTicks = startTimeTicks,
                     SubtitleMethod = SubtitleDeliveryMethod.Hls,
                     SubtitleMethod = SubtitleDeliveryMethod.Hls,
                     RequireAvc = true,
                     RequireAvc = true,
@@ -244,7 +244,7 @@ namespace Jellyfin.Api.Controllers
                 BreakOnNonKeyFrames = breakOnNonKeyFrames,
                 BreakOnNonKeyFrames = breakOnNonKeyFrames,
                 AudioSampleRate = maxAudioSampleRate,
                 AudioSampleRate = maxAudioSampleRate,
                 MaxAudioChannels = maxAudioChannels,
                 MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)),
+                AudioBitRate = isStatic ? (int?)null : (audioBitRate ?? maxStreamingBitrate),
                 MaxAudioBitDepth = maxAudioBitDepth,
                 MaxAudioBitDepth = maxAudioBitDepth,
                 AudioChannels = maxAudioChannels,
                 AudioChannels = maxAudioChannels,
                 CopyTimestamps = true,
                 CopyTimestamps = true,
@@ -270,20 +270,24 @@ namespace Jellyfin.Api.Controllers
         {
         {
             var deviceProfile = new DeviceProfile();
             var deviceProfile = new DeviceProfile();
 
 
-            var directPlayProfiles = new List<DirectPlayProfile>();
-
             var containers = RequestHelpers.Split(container, ',', true);
             var containers = RequestHelpers.Split(container, ',', true);
-
-            foreach (var cont in containers)
+            int len = containers.Length;
+            var directPlayProfiles = new DirectPlayProfile[len];
+            for (int i = 0; i < len; i++)
             {
             {
-                var parts = RequestHelpers.Split(cont, ',', true);
+                var parts = RequestHelpers.Split(containers[i], '|', true);
 
 
-                var audioCodecs = parts.Length == 1 ? null : string.Join(",", parts.Skip(1).ToArray());
+                var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1));
 
 
-                directPlayProfiles.Add(new DirectPlayProfile { Type = DlnaProfileType.Audio, Container = parts[0], AudioCodec = audioCodecs });
+                directPlayProfiles[i] = new DirectPlayProfile
+                {
+                    Type = DlnaProfileType.Audio,
+                    Container = parts[0],
+                    AudioCodec = audioCodecs
+                };
             }
             }
 
 
-            deviceProfile.DirectPlayProfiles = directPlayProfiles.ToArray();
+            deviceProfile.DirectPlayProfiles = directPlayProfiles;
 
 
             deviceProfile.TranscodingProfiles = new[]
             deviceProfile.TranscodingProfiles = new[]
             {
             {

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

@@ -530,6 +530,33 @@ namespace Jellyfin.Api.Controllers
             return result;
             return result;
         }
         }
 
 
+        /// <summary>
+        /// Gets the user based on auth token.
+        /// </summary>
+        /// <response code="200">User returned.</response>
+        /// <response code="400">Token is not owned by a user.</response>
+        /// <returns>A <see cref="UserDto"/> for the authenticated user.</returns>
+        [HttpGet("Me")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status400BadRequest)]
+        public ActionResult<UserDto> GetCurrentUser()
+        {
+            var userId = ClaimHelpers.GetUserId(Request.HttpContext.User);
+            if (userId == null)
+            {
+                return BadRequest();
+            }
+
+            var user = _userManager.GetUserById(userId.Value);
+            if (user == null)
+            {
+                return BadRequest();
+            }
+
+            return _userManager.GetUserDto(user);
+        }
+
         private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
         private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
         {
         {
             var users = _userManager.Users;
             var users = _userManager.Users;

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

@@ -63,6 +63,19 @@ namespace Jellyfin.Api.Helpers
         public static string? GetToken(in ClaimsPrincipal user)
         public static string? GetToken(in ClaimsPrincipal user)
             => GetClaimValue(user, InternalClaimTypes.Token);
             => GetClaimValue(user, InternalClaimTypes.Token);
 
 
+        /// <summary>
+        /// Gets a flag specifying whether the request is using an api key.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>The flag specifying whether the request is using an api key.</returns>
+        public static bool GetIsApiKey(in ClaimsPrincipal user)
+        {
+            var claimValue = GetClaimValue(user, InternalClaimTypes.IsApiKey);
+            return !string.IsNullOrEmpty(claimValue)
+                   && bool.TryParse(claimValue, out var parsedClaimValue)
+                   && parsedClaimValue;
+        }
+
         private static string? GetClaimValue(in ClaimsPrincipal user, string name)
         private static string? GetClaimValue(in ClaimsPrincipal user, string name)
         {
         {
             return user?.Identities
             return user?.Identities

+ 2 - 3
Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs

@@ -123,9 +123,8 @@ namespace Jellyfin.Api.Helpers
                     state.Dispose();
                     state.Dispose();
                 }
                 }
 
 
-                await new ProgressiveFileCopier(outputPath, job, transcodingJobHelper, CancellationToken.None)
-                    .WriteToAsync(httpContext.Response.Body, CancellationToken.None).ConfigureAwait(false);
-                return new FileStreamResult(httpContext.Response.Body, contentType);
+                var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper);
+                return new FileStreamResult(stream, contentType);
             }
             }
             finally
             finally
             {
             {

+ 2 - 2
Jellyfin.Api/Helpers/MediaInfoHelper.cs

@@ -166,7 +166,7 @@ namespace Jellyfin.Api.Helpers
             MediaSourceInfo mediaSource,
             MediaSourceInfo mediaSource,
             DeviceProfile profile,
             DeviceProfile profile,
             AuthorizationInfo auth,
             AuthorizationInfo auth,
-            long? maxBitrate,
+            int? maxBitrate,
             long startTimeTicks,
             long startTimeTicks,
             string mediaSourceId,
             string mediaSourceId,
             int? audioStreamIndex,
             int? audioStreamIndex,
@@ -551,7 +551,7 @@ namespace Jellyfin.Api.Helpers
             }
             }
         }
         }
 
 
-        private long? GetMaxBitrate(long? clientMaxBitrate, User user, string ipAddress)
+        private int? GetMaxBitrate(int? clientMaxBitrate, User user, string ipAddress)
         {
         {
             var maxBitrate = clientMaxBitrate;
             var maxBitrate = clientMaxBitrate;
             var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0;
             var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0;

+ 166 - 0
Jellyfin.Api/Helpers/ProgressiveFileStream.cs

@@ -0,0 +1,166 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.PlaybackDtos;
+using MediaBrowser.Model.IO;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// A progressive file stream for transferring transcoded files as they are written to.
+    /// </summary>
+    public class ProgressiveFileStream : Stream
+    {
+        private readonly FileStream _fileStream;
+        private readonly TranscodingJobDto? _job;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+        private readonly bool _allowAsyncFileRead;
+        private int _bytesWritten;
+        private bool _disposed;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class.
+        /// </summary>
+        /// <param name="filePath">The path to the transcoded file.</param>
+        /// <param name="job">The transcoding job information.</param>
+        /// <param name="transcodingJobHelper">The transcoding job helper.</param>
+        public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper)
+        {
+            _job = job;
+            _transcodingJobHelper = transcodingJobHelper;
+            _bytesWritten = 0;
+
+            var fileOptions = FileOptions.SequentialScan;
+            _allowAsyncFileRead = false;
+
+            // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039
+            if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+            {
+                fileOptions |= FileOptions.Asynchronous;
+                _allowAsyncFileRead = true;
+            }
+
+            _fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions);
+        }
+
+        /// <inheritdoc />
+        public override bool CanRead => _fileStream.CanRead;
+
+        /// <inheritdoc />
+        public override bool CanSeek => false;
+
+        /// <inheritdoc />
+        public override bool CanWrite => false;
+
+        /// <inheritdoc />
+        public override long Length => throw new NotSupportedException();
+
+        /// <inheritdoc />
+        public override long Position
+        {
+            get => throw new NotSupportedException();
+            set => throw new NotSupportedException();
+        }
+
+        /// <inheritdoc />
+        public override void Flush()
+        {
+            _fileStream.Flush();
+        }
+
+        /// <inheritdoc />
+        public override int Read(byte[] buffer, int offset, int count)
+        {
+            return _fileStream.Read(buffer, offset, count);
+        }
+
+        /// <inheritdoc />
+        public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+        {
+            int totalBytesRead = 0;
+            int remainingBytesToRead = count;
+
+            int newOffset = offset;
+            while (remainingBytesToRead > 0)
+            {
+                cancellationToken.ThrowIfCancellationRequested();
+                int bytesRead;
+                if (_allowAsyncFileRead)
+                {
+                    bytesRead = await _fileStream.ReadAsync(buffer, newOffset, remainingBytesToRead, cancellationToken).ConfigureAwait(false);
+                }
+                else
+                {
+                    bytesRead = _fileStream.Read(buffer, newOffset, remainingBytesToRead);
+                }
+
+                remainingBytesToRead -= bytesRead;
+                newOffset += bytesRead;
+
+                if (bytesRead > 0)
+                {
+                    _bytesWritten += bytesRead;
+                    totalBytesRead += bytesRead;
+
+                    if (_job != null)
+                    {
+                        _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten);
+                    }
+                }
+                else
+                {
+                    // If the job is null it's a live stream and will require user action to close
+                    if (_job?.HasExited ?? false)
+                    {
+                        break;
+                    }
+
+                    await Task.Delay(50, cancellationToken).ConfigureAwait(false);
+                }
+            }
+
+            return totalBytesRead;
+        }
+
+        /// <inheritdoc />
+        public override long Seek(long offset, SeekOrigin origin)
+            => throw new NotSupportedException();
+
+        /// <inheritdoc />
+        public override void SetLength(long value)
+            => throw new NotSupportedException();
+
+        /// <inheritdoc />
+        public override void Write(byte[] buffer, int offset, int count)
+            => throw new NotSupportedException();
+
+        /// <inheritdoc />
+        protected override void Dispose(bool disposing)
+        {
+            if (_disposed)
+            {
+                return;
+            }
+
+            try
+            {
+                if (disposing)
+                {
+                    _fileStream.Dispose();
+
+                    if (_job != null)
+                    {
+                        _transcodingJobHelper.OnTranscodeEndRequest(_job);
+                    }
+                }
+            }
+            finally
+            {
+                _disposed = true;
+                base.Dispose(disposing);
+            }
+        }
+    }
+}

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

@@ -1,9 +1,14 @@
 using System;
 using System;
 using System.Linq;
 using System.Linq;
+using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
 
 
@@ -159,5 +164,67 @@ namespace Jellyfin.Api.Helpers
                 .Select(i => i!.Value)
                 .Select(i => i!.Value)
                 .ToArray();
                 .ToArray();
         }
         }
+
+        /// <summary>
+        /// Gets the item fields.
+        /// </summary>
+        /// <param name="imageTypes">The image types string.</param>
+        /// <returns>IEnumerable{ItemFields}.</returns>
+        internal static ImageType[] GetImageTypes(string? imageTypes)
+        {
+            if (string.IsNullOrEmpty(imageTypes))
+            {
+                return Array.Empty<ImageType>();
+            }
+
+            return Split(imageTypes, ',', true)
+                .Select(v =>
+                {
+                    if (Enum.TryParse(v, true, out ImageType value))
+                    {
+                        return (ImageType?)value;
+                    }
+
+                    return null;
+                })
+                .Where(i => i.HasValue)
+                .Select(i => i!.Value)
+                .ToArray();
+        }
+
+        internal static QueryResult<BaseItemDto> CreateQueryResult(
+            QueryResult<(BaseItem, ItemCounts)> result,
+            DtoOptions dtoOptions,
+            IDtoService dtoService,
+            bool includeItemTypes,
+            User? user)
+        {
+            var dtos = result.Items.Select(i =>
+            {
+                var (baseItem, counts) = i;
+                var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+                if (includeItemTypes)
+                {
+                    dto.ChildCount = counts.ItemCount;
+                    dto.ProgramCount = counts.ProgramCount;
+                    dto.SeriesCount = counts.SeriesCount;
+                    dto.EpisodeCount = counts.EpisodeCount;
+                    dto.MovieCount = counts.MovieCount;
+                    dto.TrailerCount = counts.TrailerCount;
+                    dto.AlbumCount = counts.AlbumCount;
+                    dto.SongCount = counts.SongCount;
+                    dto.ArtistCount = counts.ArtistCount;
+                }
+
+                return dto;
+            });
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos.ToArray(),
+                TotalRecordCount = result.TotalRecordCount
+            };
+        }
     }
     }
 }
 }

+ 2 - 2
Jellyfin.Api/Helpers/SimilarItemsHelper.cs

@@ -50,9 +50,9 @@ namespace Jellyfin.Api.Helpers
 
 
             var returnItems = items;
             var returnItems = items;
 
 
-            if (limit.HasValue)
+            if (limit.HasValue && limit < returnItems.Count)
             {
             {
-                returnItems = returnItems.Take(limit.Value).ToList();
+                returnItems = returnItems.GetRange(0, limit.Value);
             }
             }
 
 
             var dtos = dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
             var dtos = dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);

+ 1 - 1
Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs

@@ -15,7 +15,7 @@ namespace Jellyfin.Api.ModelBinders
         public Task BindModelAsync(ModelBindingContext bindingContext)
         public Task BindModelAsync(ModelBindingContext bindingContext)
         {
         {
             var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
             var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
-            var elementType = bindingContext.ModelType.GetElementType();
+            var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
             var converter = TypeDescriptor.GetConverter(elementType);
             var converter = TypeDescriptor.GetConverter(elementType);
 
 
             if (valueProviderResult.Length > 1)
             if (valueProviderResult.Length > 1)

+ 34 - 0
Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs

@@ -0,0 +1,34 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Jellyfin.Api.Models.SubtitleDtos
+{
+    /// <summary>
+    /// Upload subtitles dto.
+    /// </summary>
+    public class UploadSubtitleDto
+    {
+        /// <summary>
+        /// Gets or sets the subtitle language.
+        /// </summary>
+        [Required]
+        public string Language { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the subtitle format.
+        /// </summary>
+        [Required]
+        public string Format { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the subtitle is forced.
+        /// </summary>
+        [Required]
+        public bool IsForced { get; set; }
+
+        /// <summary>
+        /// Gets or sets the subtitle data.
+        /// </summary>
+        [Required]
+        public string Data { get; set; } = string.Empty;
+    }
+}

+ 16 - 1
Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs

@@ -4,6 +4,7 @@ using System.IO;
 using System.Linq;
 using System.Linq;
 using System.Net;
 using System.Net;
 using System.Reflection;
 using System.Reflection;
+using Emby.Server.Implementations;
 using Jellyfin.Api.Auth;
 using Jellyfin.Api.Auth;
 using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
 using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
 using Jellyfin.Api.Auth.DownloadPolicy;
 using Jellyfin.Api.Auth.DownloadPolicy;
@@ -27,6 +28,8 @@ using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Cors.Infrastructure;
 using Microsoft.AspNetCore.Cors.Infrastructure;
 using Microsoft.AspNetCore.HttpOverrides;
 using Microsoft.AspNetCore.HttpOverrides;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.OpenApi.Any;
+using Microsoft.OpenApi.Interfaces;
 using Microsoft.OpenApi.Models;
 using Microsoft.OpenApi.Models;
 using Swashbuckle.AspNetCore.SwaggerGen;
 using Swashbuckle.AspNetCore.SwaggerGen;
 using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
 using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
@@ -209,7 +212,19 @@ namespace Jellyfin.Server.Extensions
         {
         {
             return serviceCollection.AddSwaggerGen(c =>
             return serviceCollection.AddSwaggerGen(c =>
             {
             {
-                c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" });
+                c.SwaggerDoc("api-docs", new OpenApiInfo
+                {
+                    Title = "Jellyfin API",
+                    Version = "v1",
+                    Extensions = new Dictionary<string, IOpenApiExtension>
+                    {
+                        {
+                            "x-jellyfin-version",
+                            new OpenApiString(typeof(ApplicationHost).Assembly.GetName().Version?.ToString())
+                        }
+                    }
+                });
+
                 c.AddSecurityDefinition(AuthenticationSchemes.CustomAuthentication, new OpenApiSecurityScheme
                 c.AddSecurityDefinition(AuthenticationSchemes.CustomAuthentication, new OpenApiSecurityScheme
                 {
                 {
                     Type = SecuritySchemeType.ApiKey,
                     Type = SecuritySchemeType.ApiKey,

+ 2 - 2
MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs

@@ -21,8 +21,8 @@ namespace MediaBrowser.Common.Json.Converters
         /// <inheritdoc />
         /// <inheritdoc />
         public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
         public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
         {
         {
-            var structType = typeToConvert.GetElementType();
+            var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0];
             return (JsonConverter)Activator.CreateInstance(typeof(JsonCommaDelimitedArrayConverter<>).MakeGenericType(structType));
             return (JsonConverter)Activator.CreateInstance(typeof(JsonCommaDelimitedArrayConverter<>).MakeGenericType(structType));
         }
         }
     }
     }
-}
+}

+ 113 - 0
MediaBrowser.Common/Plugins/LocalPlugin.cs

@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace MediaBrowser.Common.Plugins
+{
+    /// <summary>
+    /// Local plugin struct.
+    /// </summary>
+    public class LocalPlugin : IEquatable<LocalPlugin>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="LocalPlugin"/> class.
+        /// </summary>
+        /// <param name="id">The plugin id.</param>
+        /// <param name="name">The plugin name.</param>
+        /// <param name="version">The plugin version.</param>
+        /// <param name="path">The plugin path.</param>
+        public LocalPlugin(Guid id, string name, Version version, string path)
+        {
+            Id = id;
+            Name = name;
+            Version = version;
+            Path = path;
+            DllFiles = new List<string>();
+        }
+
+        /// <summary>
+        /// Gets the plugin id.
+        /// </summary>
+        public Guid Id { get; }
+
+        /// <summary>
+        /// Gets the plugin name.
+        /// </summary>
+        public string Name { get; }
+
+        /// <summary>
+        /// Gets the plugin version.
+        /// </summary>
+        public Version Version { get; }
+
+        /// <summary>
+        /// Gets the plugin path.
+        /// </summary>
+        public string Path { get; }
+
+        /// <summary>
+        /// Gets the list of dll files for this plugin.
+        /// </summary>
+        public List<string> DllFiles { get; }
+
+        /// <summary>
+        /// == operator.
+        /// </summary>
+        /// <param name="left">Left item.</param>
+        /// <param name="right">Right item.</param>
+        /// <returns>Comparison result.</returns>
+        public static bool operator ==(LocalPlugin left, LocalPlugin right)
+        {
+            return left.Equals(right);
+        }
+
+        /// <summary>
+        /// != operator.
+        /// </summary>
+        /// <param name="left">Left item.</param>
+        /// <param name="right">Right item.</param>
+        /// <returns>Comparison result.</returns>
+        public static bool operator !=(LocalPlugin left, LocalPlugin right)
+        {
+            return !left.Equals(right);
+        }
+
+        /// <summary>
+        /// Compare two <see cref="LocalPlugin"/>.
+        /// </summary>
+        /// <param name="a">The first item.</param>
+        /// <param name="b">The second item.</param>
+        /// <returns>Comparison result.</returns>
+        public static int Compare(LocalPlugin a, LocalPlugin b)
+        {
+            var compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture);
+
+            // Id is not equal but name is.
+            if (a.Id != b.Id && compare == 0)
+            {
+                compare = a.Id.CompareTo(b.Id);
+            }
+
+            return compare == 0 ? a.Version.CompareTo(b.Version) : compare;
+        }
+
+        /// <inheritdoc />
+        public override bool Equals(object obj)
+        {
+            return obj is LocalPlugin other && this.Equals(other);
+        }
+
+        /// <inheritdoc />
+        public override int GetHashCode()
+        {
+            return Name.GetHashCode(StringComparison.OrdinalIgnoreCase);
+        }
+
+        /// <inheritdoc />
+        public bool Equals(LocalPlugin other)
+        {
+            return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase)
+                   && Id.Equals(other.Id);
+        }
+    }
+}

+ 5 - 0
MediaBrowser.Controller/Entities/InternalPeopleQuery.cs

@@ -1,6 +1,7 @@
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System;
 using System;
+using Jellyfin.Data.Entities;
 
 
 namespace MediaBrowser.Controller.Entities
 namespace MediaBrowser.Controller.Entities
 {
 {
@@ -23,6 +24,10 @@ namespace MediaBrowser.Controller.Entities
 
 
         public string NameContains { get; set; }
         public string NameContains { get; set; }
 
 
+        public User User { get; set; }
+
+        public bool? IsFavorite { get; set; }
+
         public InternalPeopleQuery()
         public InternalPeopleQuery()
         {
         {
             PersonTypes = Array.Empty<string>();
             PersonTypes = Array.Empty<string>();

+ 6 - 0
MediaBrowser.Controller/IDisplayPreferencesManager.cs

@@ -12,6 +12,9 @@ namespace MediaBrowser.Controller
         /// <summary>
         /// <summary>
         /// Gets the display preferences for the user and client.
         /// Gets the display preferences for the user and client.
         /// </summary>
         /// </summary>
+        /// <remarks>
+        /// This will create the display preferences if it does not exist, but it will not save automatically.
+        /// </remarks>
         /// <param name="userId">The user's id.</param>
         /// <param name="userId">The user's id.</param>
         /// <param name="client">The client string.</param>
         /// <param name="client">The client string.</param>
         /// <returns>The associated display preferences.</returns>
         /// <returns>The associated display preferences.</returns>
@@ -20,6 +23,9 @@ namespace MediaBrowser.Controller
         /// <summary>
         /// <summary>
         /// Gets the default item display preferences for the user and client.
         /// Gets the default item display preferences for the user and client.
         /// </summary>
         /// </summary>
+        /// <remarks>
+        /// This will create the item display preferences if it does not exist, but it will not save automatically.
+        /// </remarks>
         /// <param name="userId">The user id.</param>
         /// <param name="userId">The user id.</param>
         /// <param name="itemId">The item id.</param>
         /// <param name="itemId">The item id.</param>
         /// <param name="client">The client string.</param>
         /// <param name="client">The client string.</param>

+ 14 - 5
MediaBrowser.Controller/IServerApplicationHost.cs

@@ -6,8 +6,8 @@ using System.Net;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using MediaBrowser.Common;
 using MediaBrowser.Common;
+using MediaBrowser.Common.Plugins;
 using MediaBrowser.Model.System;
 using MediaBrowser.Model.System;
-using Microsoft.AspNetCore.Http;
 
 
 namespace MediaBrowser.Controller
 namespace MediaBrowser.Controller
 {
 {
@@ -56,10 +56,11 @@ namespace MediaBrowser.Controller
         /// <summary>
         /// <summary>
         /// Gets the system info.
         /// Gets the system info.
         /// </summary>
         /// </summary>
+        /// <param name="cancellationToken">A cancellation token that can be used to cancel the task.</param>
         /// <returns>SystemInfo.</returns>
         /// <returns>SystemInfo.</returns>
-        Task<SystemInfo> GetSystemInfo(CancellationToken cancellationToken);
+        Task<SystemInfo> GetSystemInfo(CancellationToken cancellationToken = default);
 
 
-        Task<PublicSystemInfo> GetPublicSystemInfo(CancellationToken cancellationToken);
+        Task<PublicSystemInfo> GetPublicSystemInfo(CancellationToken cancellationToken = default);
 
 
         /// <summary>
         /// <summary>
         /// Gets all the local IP addresses of this API instance. Each address is validated by sending a 'ping' request
         /// Gets all the local IP addresses of this API instance. Each address is validated by sending a 'ping' request
@@ -67,7 +68,7 @@ namespace MediaBrowser.Controller
         /// </summary>
         /// </summary>
         /// <param name="cancellationToken">A cancellation token that can be used to cancel the task.</param>
         /// <param name="cancellationToken">A cancellation token that can be used to cancel the task.</param>
         /// <returns>A list containing all the local IP addresses of the server.</returns>
         /// <returns>A list containing all the local IP addresses of the server.</returns>
-        Task<List<IPAddress>> GetLocalIpAddresses(CancellationToken cancellationToken);
+        Task<List<IPAddress>> GetLocalIpAddresses(CancellationToken cancellationToken = default);
 
 
         /// <summary>
         /// <summary>
         /// Gets a local (LAN) URL that can be used to access the API. The hostname used is the first valid configured
         /// Gets a local (LAN) URL that can be used to access the API. The hostname used is the first valid configured
@@ -75,7 +76,7 @@ namespace MediaBrowser.Controller
         /// </summary>
         /// </summary>
         /// <param name="cancellationToken">A cancellation token that can be used to cancel the task.</param>
         /// <param name="cancellationToken">A cancellation token that can be used to cancel the task.</param>
         /// <returns>The server URL.</returns>
         /// <returns>The server URL.</returns>
-        Task<string> GetLocalApiUrl(CancellationToken cancellationToken);
+        Task<string> GetLocalApiUrl(CancellationToken cancellationToken = default);
 
 
         /// <summary>
         /// <summary>
         /// Gets a localhost URL that can be used to access the API using the loop-back IP address (127.0.0.1)
         /// Gets a localhost URL that can be used to access the API using the loop-back IP address (127.0.0.1)
@@ -119,5 +120,13 @@ namespace MediaBrowser.Controller
         string ExpandVirtualPath(string path);
         string ExpandVirtualPath(string path);
 
 
         string ReverseVirtualPath(string path);
         string ReverseVirtualPath(string path);
+
+        /// <summary>
+        /// Gets the list of local plugins.
+        /// </summary>
+        /// <param name="path">Plugin base directory.</param>
+        /// <param name="cleanup">Cleanup old plugins.</param>
+        /// <returns>Enumerable of local plugins.</returns>
+        IEnumerable<LocalPlugin> GetLocalPlugins(string path, bool cleanup = true);
     }
     }
 }
 }

+ 2 - 0
MediaBrowser.Controller/Library/ILibraryManager.cs

@@ -570,5 +570,7 @@ namespace MediaBrowser.Controller.Library
             List<MediaStream> streams,
             List<MediaStream> streams,
             string videoPath,
             string videoPath,
             string[] files);
             string[] files);
+
+        BaseItem GetParentItem(string parentId, Guid? userId);
     }
     }
 }
 }

+ 2 - 0
MediaBrowser.Controller/Library/IMediaSourceManager.cs

@@ -115,5 +115,7 @@ namespace MediaBrowser.Controller.Library
     public interface IDirectStreamProvider
     public interface IDirectStreamProvider
     {
     {
         Task CopyToAsync(Stream stream, CancellationToken cancellationToken);
         Task CopyToAsync(Stream stream, CancellationToken cancellationToken);
+
+        string GetFilePath();
     }
     }
 }
 }

+ 4 - 3
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -2675,9 +2675,10 @@ namespace MediaBrowser.Controller.MediaEncoding
             state.MediaSource = mediaSource;
             state.MediaSource = mediaSource;
 
 
             var request = state.BaseRequest;
             var request = state.BaseRequest;
-            if (!string.IsNullOrWhiteSpace(request.AudioCodec))
+            var supportedAudioCodecs = state.SupportedAudioCodecs;
+            if (request != null && supportedAudioCodecs != null && supportedAudioCodecs.Length > 0)
             {
             {
-                var supportedAudioCodecsList = request.AudioCodec.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList();
+                var supportedAudioCodecsList = supportedAudioCodecs.ToList();
 
 
                 ShiftAudioCodecsIfNeeded(supportedAudioCodecsList, state.AudioStream);
                 ShiftAudioCodecsIfNeeded(supportedAudioCodecsList, state.AudioStream);
 
 
@@ -3084,7 +3085,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 }
                 }
             }
             }
 
 
-            var whichCodec = videoStream.Codec.ToLowerInvariant();
+            var whichCodec = videoStream.Codec?.ToLowerInvariant();
             switch (whichCodec)
             switch (whichCodec)
             {
             {
                 case "avc":
                 case "avc":

+ 5 - 0
MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs

@@ -287,6 +287,11 @@ namespace MediaBrowser.Controller.MediaEncoding
                 return BaseRequest.AudioChannels;
                 return BaseRequest.AudioChannels;
             }
             }
 
 
+            if (BaseRequest.TranscodingMaxAudioChannels.HasValue)
+            {
+                return BaseRequest.TranscodingMaxAudioChannels;
+            }
+
             if (!string.IsNullOrEmpty(codec))
             if (!string.IsNullOrEmpty(codec))
             {
             {
                 var value = BaseRequest.GetOption(codec, "audiochannels");
                 var value = BaseRequest.GetOption(codec, "audiochannels");

+ 16 - 2
MediaBrowser.Controller/Net/AuthorizationInfo.cs

@@ -1,10 +1,11 @@
-#pragma warning disable CS1591
-
 using System;
 using System;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 
 
 namespace MediaBrowser.Controller.Net
 namespace MediaBrowser.Controller.Net
 {
 {
+    /// <summary>
+    /// The request authorization info.
+    /// </summary>
     public class AuthorizationInfo
     public class AuthorizationInfo
     {
     {
         /// <summary>
         /// <summary>
@@ -43,6 +44,19 @@ namespace MediaBrowser.Controller.Net
         /// <value>The token.</value>
         /// <value>The token.</value>
         public string Token { get; set; }
         public string Token { get; set; }
 
 
+        /// <summary>
+        /// Gets or sets a value indicating whether the authorization is from an api key.
+        /// </summary>
+        public bool IsApiKey { get; set; }
+
+        /// <summary>
+        /// Gets or sets the user making the request.
+        /// </summary>
         public User User { get; set; }
         public User User { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the token is authenticated.
+        /// </summary>
+        public bool IsAuthenticated { get; set; }
     }
     }
 }
 }

+ 9 - 0
MediaBrowser.Controller/Subtitles/ISubtitleManager.cs

@@ -2,6 +2,7 @@
 
 
 using System;
 using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
+using System.IO;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
@@ -52,6 +53,14 @@ namespace MediaBrowser.Controller.Subtitles
         /// </summary>
         /// </summary>
         Task DownloadSubtitles(Video video, LibraryOptions libraryOptions, string subtitleId, CancellationToken cancellationToken);
         Task DownloadSubtitles(Video video, LibraryOptions libraryOptions, string subtitleId, CancellationToken cancellationToken);
 
 
+        /// <summary>
+        /// Upload new subtitle.
+        /// </summary>
+        /// <param name="video">The video the subtitle belongs to.</param>
+        /// <param name="response">The subtitle response.</param>
+        /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+        Task UploadSubtitle(Video video, SubtitleResponse response);
+
         /// <summary>
         /// <summary>
         /// Gets the remote subtitles.
         /// Gets the remote subtitles.
         /// </summary>
         /// </summary>

+ 5 - 0
MediaBrowser.Model/Configuration/EncodingOptions.cs

@@ -9,6 +9,10 @@ namespace MediaBrowser.Model.Configuration
 
 
         public string TranscodingTempPath { get; set; }
         public string TranscodingTempPath { get; set; }
 
 
+        public string FallbackFontPath { get; set; }
+
+        public bool EnableFallbackFont { get; set; }
+
         public double DownMixAudioBoost { get; set; }
         public double DownMixAudioBoost { get; set; }
 
 
         public int MaxMuxingQueueSize { get; set; }
         public int MaxMuxingQueueSize { get; set; }
@@ -69,6 +73,7 @@ namespace MediaBrowser.Model.Configuration
 
 
         public EncodingOptions()
         public EncodingOptions()
         {
         {
+            EnableFallbackFont = false;
             DownMixAudioBoost = 2;
             DownMixAudioBoost = 2;
             MaxMuxingQueueSize = 2048;
             MaxMuxingQueueSize = 2048;
             EnableThrottling = false;
             EnableThrottling = false;

+ 2 - 2
MediaBrowser.Model/Dlna/AudioOptions.cs

@@ -49,7 +49,7 @@ namespace MediaBrowser.Model.Dlna
         /// <summary>
         /// <summary>
         /// The application's configured quality setting.
         /// The application's configured quality setting.
         /// </summary>
         /// </summary>
-        public long? MaxBitrate { get; set; }
+        public int? MaxBitrate { get; set; }
 
 
         /// <summary>
         /// <summary>
         /// Gets or sets the context.
         /// Gets or sets the context.
@@ -67,7 +67,7 @@ namespace MediaBrowser.Model.Dlna
         /// Gets the maximum bitrate.
         /// Gets the maximum bitrate.
         /// </summary>
         /// </summary>
         /// <returns>System.Nullable&lt;System.Int32&gt;.</returns>
         /// <returns>System.Nullable&lt;System.Int32&gt;.</returns>
-        public long? GetMaxBitrate(bool isAudio)
+        public int? GetMaxBitrate(bool isAudio)
         {
         {
             if (MaxBitrate.HasValue)
             if (MaxBitrate.HasValue)
             {
             {

+ 2 - 2
MediaBrowser.Model/Dlna/DeviceProfile.cs

@@ -62,9 +62,9 @@ namespace MediaBrowser.Model.Dlna
 
 
         public int? MaxIconHeight { get; set; }
         public int? MaxIconHeight { get; set; }
 
 
-        public long? MaxStreamingBitrate { get; set; }
+        public int? MaxStreamingBitrate { get; set; }
 
 
-        public long? MaxStaticBitrate { get; set; }
+        public int? MaxStaticBitrate { get; set; }
 
 
         public int? MusicStreamingTranscodingBitrate { get; set; }
         public int? MusicStreamingTranscodingBitrate { get; set; }
 
 

+ 1 - 1
MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs

@@ -37,7 +37,7 @@ namespace MediaBrowser.Model.MediaInfo
 
 
         public string PlaySessionId { get; set; }
         public string PlaySessionId { get; set; }
 
 
-        public long? MaxStreamingBitrate { get; set; }
+        public int? MaxStreamingBitrate { get; set; }
 
 
         public long? StartTimeTicks { get; set; }
         public long? StartTimeTicks { get; set; }
 
 

+ 34 - 0
MediaBrowser.Model/Subtitles/FontFile.cs

@@ -0,0 +1,34 @@
+using System;
+
+namespace MediaBrowser.Model.Subtitles
+{
+    /// <summary>
+    /// Class FontFile.
+    /// </summary>
+    public class FontFile
+    {
+        /// <summary>
+        /// Gets or sets the name.
+        /// </summary>
+        /// <value>The name.</value>
+        public string? Name { get; set; }
+
+        /// <summary>
+        /// Gets or sets the size.
+        /// </summary>
+        /// <value>The size.</value>
+        public long Size { get; set; }
+
+        /// <summary>
+        /// Gets or sets the date created.
+        /// </summary>
+        /// <value>The date created.</value>
+        public DateTime DateCreated { get; set; }
+
+        /// <summary>
+        /// Gets or sets the date modified.
+        /// </summary>
+        /// <value>The date modified.</value>
+        public DateTime DateModified { get; set; }
+    }
+}

+ 42 - 27
MediaBrowser.Providers/Subtitles/SubtitleManager.cs

@@ -150,37 +150,11 @@ namespace MediaBrowser.Providers.Subtitles
             var parts = subtitleId.Split(new[] { '_' }, 2);
             var parts = subtitleId.Split(new[] { '_' }, 2);
             var provider = GetProvider(parts[0]);
             var provider = GetProvider(parts[0]);
 
 
-            var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia;
-
             try
             try
             {
             {
                 var response = await GetRemoteSubtitles(subtitleId, cancellationToken).ConfigureAwait(false);
                 var response = await GetRemoteSubtitles(subtitleId, cancellationToken).ConfigureAwait(false);
 
 
-                using (var stream = response.Stream)
-                using (var memoryStream = new MemoryStream())
-                {
-                    await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
-                    memoryStream.Position = 0;
-
-                    var savePaths = new List<string>();
-                    var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant();
-
-                    if (response.IsForced)
-                    {
-                        saveFileName += ".forced";
-                    }
-
-                    saveFileName += "." + response.Format.ToLowerInvariant();
-
-                    if (saveInMediaFolder)
-                    {
-                        savePaths.Add(Path.Combine(video.ContainingFolderPath, saveFileName));
-                    }
-
-                    savePaths.Add(Path.Combine(video.GetInternalMetadataPath(), saveFileName));
-
-                    await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
-                }
+                await TrySaveSubtitle(video, libraryOptions, response).ConfigureAwait(false);
             }
             }
             catch (RateLimitExceededException)
             catch (RateLimitExceededException)
             {
             {
@@ -199,6 +173,47 @@ namespace MediaBrowser.Providers.Subtitles
             }
             }
         }
         }
 
 
+        /// <inheritdoc />
+        public Task UploadSubtitle(Video video, SubtitleResponse response)
+        {
+            var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video);
+            return TrySaveSubtitle(video, libraryOptions, response);
+        }
+
+        private async Task TrySaveSubtitle(
+            Video video,
+            LibraryOptions libraryOptions,
+            SubtitleResponse response)
+        {
+            var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia;
+
+            using (var stream = response.Stream)
+            using (var memoryStream = new MemoryStream())
+            {
+                await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
+                memoryStream.Position = 0;
+
+                var savePaths = new List<string>();
+                var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant();
+
+                if (response.IsForced)
+                {
+                    saveFileName += ".forced";
+                }
+
+                saveFileName += "." + response.Format.ToLowerInvariant();
+
+                if (saveInMediaFolder)
+                {
+                    savePaths.Add(Path.Combine(video.ContainingFolderPath, saveFileName));
+                }
+
+                savePaths.Add(Path.Combine(video.GetInternalMetadataPath(), saveFileName));
+
+                await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
+            }
+        }
+
         private async Task TrySaveToFiles(Stream stream, List<string> savePaths)
         private async Task TrySaveToFiles(Stream stream, List<string> savePaths)
         {
         {
             Exception exceptionToThrow = null;
             Exception exceptionToThrow = null;

+ 4 - 2
tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs

@@ -8,6 +8,7 @@ using Jellyfin.Api.Auth;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
@@ -68,14 +69,14 @@ namespace Jellyfin.Api.Tests.Auth
         }
         }
 
 
         [Fact]
         [Fact]
-        public async Task HandleAuthenticateAsyncShouldFailOnSecurityException()
+        public async Task HandleAuthenticateAsyncShouldFailOnAuthenticationException()
         {
         {
             var errorMessage = _fixture.Create<string>();
             var errorMessage = _fixture.Create<string>();
 
 
             _jellyfinAuthServiceMock.Setup(
             _jellyfinAuthServiceMock.Setup(
                     a => a.Authenticate(
                     a => a.Authenticate(
                         It.IsAny<HttpRequest>()))
                         It.IsAny<HttpRequest>()))
-                .Throws(new SecurityException(errorMessage));
+                .Throws(new AuthenticationException(errorMessage));
 
 
             var authenticateResult = await _sut.AuthenticateAsync();
             var authenticateResult = await _sut.AuthenticateAsync();
 
 
@@ -128,6 +129,7 @@ namespace Jellyfin.Api.Tests.Auth
             var authorizationInfo = _fixture.Create<AuthorizationInfo>();
             var authorizationInfo = _fixture.Create<AuthorizationInfo>();
             authorizationInfo.User = _fixture.Create<User>();
             authorizationInfo.User = _fixture.Create<User>();
             authorizationInfo.User.SetPermission(PermissionKind.IsAdministrator, isAdmin);
             authorizationInfo.User.SetPermission(PermissionKind.IsAdministrator, isAdmin);
+            authorizationInfo.IsApiKey = false;
 
 
             _jellyfinAuthServiceMock.Setup(
             _jellyfinAuthServiceMock.Setup(
                     a => a.Authenticate(
                     a => a.Authenticate(

+ 1 - 1
tests/Jellyfin.Api.Tests/TestHelpers.cs

@@ -45,7 +45,7 @@ namespace Jellyfin.Api.Tests
             {
             {
                 new Claim(ClaimTypes.Role, role),
                 new Claim(ClaimTypes.Role, role),
                 new Claim(ClaimTypes.Name, "jellyfin"),
                 new Claim(ClaimTypes.Name, "jellyfin"),
-                new Claim(InternalClaimTypes.UserId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)),
+                new Claim(InternalClaimTypes.UserId, Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)),
                 new Claim(InternalClaimTypes.DeviceId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)),
                 new Claim(InternalClaimTypes.DeviceId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)),
                 new Claim(InternalClaimTypes.Device, "test"),
                 new Claim(InternalClaimTypes.Device, "test"),
                 new Claim(InternalClaimTypes.Client, "test"),
                 new Claim(InternalClaimTypes.Client, "test"),

+ 13 - 13
tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs

@@ -11,82 +11,82 @@ namespace Jellyfin.Common.Tests.Json
         [Fact]
         [Fact]
         public static void Deserialize_String_Valid_Success()
         public static void Deserialize_String_Valid_Success()
         {
         {
-            var desiredValue = new GenericBodyModel<string>
+            var desiredValue = new GenericBodyArrayModel<string>
             {
             {
                 Value = new[] { "a", "b", "c" }
                 Value = new[] { "a", "b", "c" }
             };
             };
 
 
             var options = new JsonSerializerOptions();
             var options = new JsonSerializerOptions();
-            var value = JsonSerializer.Deserialize<GenericBodyModel<string>>(@"{ ""Value"": ""a,b,c"" }", options);
+            var value = JsonSerializer.Deserialize<GenericBodyArrayModel<string>>(@"{ ""Value"": ""a,b,c"" }", options);
             Assert.Equal(desiredValue.Value, value?.Value);
             Assert.Equal(desiredValue.Value, value?.Value);
         }
         }
 
 
         [Fact]
         [Fact]
         public static void Deserialize_String_Space_Valid_Success()
         public static void Deserialize_String_Space_Valid_Success()
         {
         {
-            var desiredValue = new GenericBodyModel<string>
+            var desiredValue = new GenericBodyArrayModel<string>
             {
             {
                 Value = new[] { "a", "b", "c" }
                 Value = new[] { "a", "b", "c" }
             };
             };
 
 
             var options = new JsonSerializerOptions();
             var options = new JsonSerializerOptions();
-            var value = JsonSerializer.Deserialize<GenericBodyModel<string>>(@"{ ""Value"": ""a, b, c"" }", options);
+            var value = JsonSerializer.Deserialize<GenericBodyArrayModel<string>>(@"{ ""Value"": ""a, b, c"" }", options);
             Assert.Equal(desiredValue.Value, value?.Value);
             Assert.Equal(desiredValue.Value, value?.Value);
         }
         }
 
 
         [Fact]
         [Fact]
         public static void Deserialize_GenericCommandType_Valid_Success()
         public static void Deserialize_GenericCommandType_Valid_Success()
         {
         {
-            var desiredValue = new GenericBodyModel<GeneralCommandType>
+            var desiredValue = new GenericBodyArrayModel<GeneralCommandType>
             {
             {
                 Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
                 Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
             };
             };
 
 
             var options = new JsonSerializerOptions();
             var options = new JsonSerializerOptions();
             options.Converters.Add(new JsonStringEnumConverter());
             options.Converters.Add(new JsonStringEnumConverter());
-            var value = JsonSerializer.Deserialize<GenericBodyModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp,MoveDown"" }", options);
+            var value = JsonSerializer.Deserialize<GenericBodyArrayModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp,MoveDown"" }", options);
             Assert.Equal(desiredValue.Value, value?.Value);
             Assert.Equal(desiredValue.Value, value?.Value);
         }
         }
 
 
         [Fact]
         [Fact]
         public static void Deserialize_GenericCommandType_Space_Valid_Success()
         public static void Deserialize_GenericCommandType_Space_Valid_Success()
         {
         {
-            var desiredValue = new GenericBodyModel<GeneralCommandType>
+            var desiredValue = new GenericBodyArrayModel<GeneralCommandType>
             {
             {
                 Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
                 Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
             };
             };
 
 
             var options = new JsonSerializerOptions();
             var options = new JsonSerializerOptions();
             options.Converters.Add(new JsonStringEnumConverter());
             options.Converters.Add(new JsonStringEnumConverter());
-            var value = JsonSerializer.Deserialize<GenericBodyModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp, MoveDown"" }", options);
+            var value = JsonSerializer.Deserialize<GenericBodyArrayModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp, MoveDown"" }", options);
             Assert.Equal(desiredValue.Value, value?.Value);
             Assert.Equal(desiredValue.Value, value?.Value);
         }
         }
 
 
         [Fact]
         [Fact]
         public static void Deserialize_String_Array_Valid_Success()
         public static void Deserialize_String_Array_Valid_Success()
         {
         {
-            var desiredValue = new GenericBodyModel<string>
+            var desiredValue = new GenericBodyArrayModel<string>
             {
             {
                 Value = new[] { "a", "b", "c" }
                 Value = new[] { "a", "b", "c" }
             };
             };
 
 
             var options = new JsonSerializerOptions();
             var options = new JsonSerializerOptions();
-            var value = JsonSerializer.Deserialize<GenericBodyModel<string>>(@"{ ""Value"": [""a"",""b"",""c""] }", options);
+            var value = JsonSerializer.Deserialize<GenericBodyArrayModel<string>>(@"{ ""Value"": [""a"",""b"",""c""] }", options);
             Assert.Equal(desiredValue.Value, value?.Value);
             Assert.Equal(desiredValue.Value, value?.Value);
         }
         }
 
 
         [Fact]
         [Fact]
         public static void Deserialize_GenericCommandType_Array_Valid_Success()
         public static void Deserialize_GenericCommandType_Array_Valid_Success()
         {
         {
-            var desiredValue = new GenericBodyModel<GeneralCommandType>
+            var desiredValue = new GenericBodyArrayModel<GeneralCommandType>
             {
             {
                 Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
                 Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
             };
             };
 
 
             var options = new JsonSerializerOptions();
             var options = new JsonSerializerOptions();
             options.Converters.Add(new JsonStringEnumConverter());
             options.Converters.Add(new JsonStringEnumConverter());
-            var value = JsonSerializer.Deserialize<GenericBodyModel<GeneralCommandType>>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", options);
+            var value = JsonSerializer.Deserialize<GenericBodyArrayModel<GeneralCommandType>>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", options);
             Assert.Equal(desiredValue.Value, value?.Value);
             Assert.Equal(desiredValue.Value, value?.Value);
         }
         }
     }
     }
-}
+}

+ 92 - 0
tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedIReadOnlyListTests.cs

@@ -0,0 +1,92 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Jellyfin.Common.Tests.Models;
+using MediaBrowser.Model.Session;
+using Xunit;
+
+namespace Jellyfin.Common.Tests.Json
+{
+    public static class JsonCommaDelimitedIReadOnlyListTests
+    {
+        [Fact]
+        public static void Deserialize_String_Valid_Success()
+        {
+            var desiredValue = new GenericBodyIReadOnlyListModel<string>
+            {
+                Value = new[] { "a", "b", "c" }
+            };
+
+            var options = new JsonSerializerOptions();
+            var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<string>>(@"{ ""Value"": ""a,b,c"" }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+
+        [Fact]
+        public static void Deserialize_String_Space_Valid_Success()
+        {
+            var desiredValue = new GenericBodyIReadOnlyListModel<string>
+            {
+                Value = new[] { "a", "b", "c" }
+            };
+
+            var options = new JsonSerializerOptions();
+            var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<string>>(@"{ ""Value"": ""a, b, c"" }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+
+        [Fact]
+        public static void Deserialize_GenericCommandType_Valid_Success()
+        {
+            var desiredValue = new GenericBodyIReadOnlyListModel<GeneralCommandType>
+            {
+                Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+            };
+
+            var options = new JsonSerializerOptions();
+            options.Converters.Add(new JsonStringEnumConverter());
+            var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp,MoveDown"" }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+
+        [Fact]
+        public static void Deserialize_GenericCommandType_Space_Valid_Success()
+        {
+            var desiredValue = new GenericBodyIReadOnlyListModel<GeneralCommandType>
+            {
+                Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+            };
+
+            var options = new JsonSerializerOptions();
+            options.Converters.Add(new JsonStringEnumConverter());
+            var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp, MoveDown"" }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+
+        [Fact]
+        public static void Deserialize_String_Array_Valid_Success()
+        {
+            var desiredValue = new GenericBodyIReadOnlyListModel<string>
+            {
+                Value = new[] { "a", "b", "c" }
+            };
+
+            var options = new JsonSerializerOptions();
+            var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<string>>(@"{ ""Value"": [""a"",""b"",""c""] }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+
+        [Fact]
+        public static void Deserialize_GenericCommandType_Array_Valid_Success()
+        {
+            var desiredValue = new GenericBodyIReadOnlyListModel<GeneralCommandType>
+            {
+                Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+            };
+
+            var options = new JsonSerializerOptions();
+            options.Converters.Add(new JsonStringEnumConverter());
+            var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<GeneralCommandType>>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+    }
+}

+ 2 - 2
tests/Jellyfin.Common.Tests/Models/GenericBodyModel.cs → tests/Jellyfin.Common.Tests/Models/GenericBodyArrayModel.cs

@@ -8,7 +8,7 @@ namespace Jellyfin.Common.Tests.Models
     /// The generic body model.
     /// The generic body model.
     /// </summary>
     /// </summary>
     /// <typeparam name="T">The value type.</typeparam>
     /// <typeparam name="T">The value type.</typeparam>
-    public class GenericBodyModel<T>
+    public class GenericBodyArrayModel<T>
     {
     {
         /// <summary>
         /// <summary>
         /// Gets or sets the value.
         /// Gets or sets the value.
@@ -17,4 +17,4 @@ namespace Jellyfin.Common.Tests.Models
         [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
         [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
         public T[] Value { get; set; } = default!;
         public T[] Value { get; set; } = default!;
     }
     }
-}
+}

+ 19 - 0
tests/Jellyfin.Common.Tests/Models/GenericBodyIReadOnlyListModel.cs

@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using MediaBrowser.Common.Json.Converters;
+
+namespace Jellyfin.Common.Tests.Models
+{
+    /// <summary>
+    /// The generic body <c>IReadOnlyList</c> model.
+    /// </summary>
+    /// <typeparam name="T">The value type.</typeparam>
+    public class GenericBodyIReadOnlyListModel<T>
+    {
+        /// <summary>
+        /// Gets or sets the value.
+        /// </summary>
+        [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+        public IReadOnlyList<T> Value { get; set; } = default!;
+    }
+}