瀏覽代碼

Merge remote-tracking branch 'jellyfin/master' into emby-namig-nullable

Stepan 4 年之前
父節點
當前提交
c0747512d6
共有 100 個文件被更改,包括 1204 次插入1153 次删除
  1. 3 0
      .ci/azure-pipelines-abi.yml
  2. 3 2
      .ci/azure-pipelines-package.yml
  3. 18 18
      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. 1 1
      Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
  13. 32 56
      Emby.Server.Implementations/ApplicationHost.cs
  14. 7 12
      Emby.Server.Implementations/Channels/ChannelManager.cs
  15. 1 1
      Emby.Server.Implementations/Channels/ChannelPostScanTask.cs
  16. 49 48
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  17. 1 1
      Emby.Server.Implementations/Dto/DtoService.cs
  18. 4 3
      Emby.Server.Implementations/HttpServer/Security/AuthService.cs
  19. 76 69
      Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
  20. 1 1
      Emby.Server.Implementations/Images/ArtistImageProvider.cs
  21. 7 2
      Emby.Server.Implementations/Images/GenreImageProvider.cs
  22. 15 0
      Emby.Server.Implementations/Library/LibraryManager.cs
  23. 2 2
      Emby.Server.Implementations/Library/MusicManager.cs
  24. 14 14
      Emby.Server.Implementations/Library/SearchEngine.cs
  25. 1 1
      Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
  26. 1 1
      Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
  27. 1 1
      Emby.Server.Implementations/Library/Validators/StudiosValidator.cs
  28. 7 7
      Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
  29. 8 2
      Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
  30. 4 4
      Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs
  31. 11 11
      Emby.Server.Implementations/LiveTv/LiveTvManager.cs
  32. 5 0
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs
  33. 4 2
      Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs
  34. 18 8
      Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
  35. 7 1
      Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs
  36. 3 1
      Emby.Server.Implementations/Localization/Core/cs.json
  37. 3 1
      Emby.Server.Implementations/Localization/Core/de.json
  38. 2 0
      Emby.Server.Implementations/Localization/Core/en-US.json
  39. 4 2
      Emby.Server.Implementations/Localization/Core/es.json
  40. 3 1
      Emby.Server.Implementations/Localization/Core/fr.json
  41. 61 59
      Emby.Server.Implementations/Localization/Core/hr.json
  42. 3 1
      Emby.Server.Implementations/Localization/Core/it.json
  43. 4 2
      Emby.Server.Implementations/Localization/Core/ja.json
  44. 4 2
      Emby.Server.Implementations/Localization/Core/ko.json
  45. 3 1
      Emby.Server.Implementations/Localization/Core/ru.json
  46. 21 21
      Emby.Server.Implementations/Localization/Core/sl-SI.json
  47. 5 3
      Emby.Server.Implementations/Localization/Core/tr.json
  48. 3 1
      Emby.Server.Implementations/Localization/Core/vi.json
  49. 3 1
      Emby.Server.Implementations/Localization/Core/zh-CN.json
  50. 3 1
      Emby.Server.Implementations/Localization/Core/zh-TW.json
  51. 4 4
      Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
  52. 78 0
      Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs
  53. 1 1
      Emby.Server.Implementations/ServerApplicationPaths.cs
  54. 2 2
      Emby.Server.Implementations/TV/TVSeriesManager.cs
  55. 24 52
      Emby.Server.Implementations/Updates/InstallationManager.cs
  56. 7 0
      Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
  57. 7 8
      Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
  58. 5 3
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
  59. 5 3
      Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs
  60. 3 3
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
  61. 5 0
      Jellyfin.Api/Constants/InternalClaimTypes.cs
  62. 11 10
      Jellyfin.Api/Controllers/ArtistsController.cs
  63. 3 2
      Jellyfin.Api/Controllers/ChannelsController.cs
  64. 6 6
      Jellyfin.Api/Controllers/CollectionController.cs
  65. 1 5
      Jellyfin.Api/Controllers/DevicesController.cs
  66. 3 0
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  67. 26 0
      Jellyfin.Api/Controllers/DlnaServerController.cs
  68. 9 3
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  69. 14 129
      Jellyfin.Api/Controllers/GenresController.cs
  70. 10 9
      Jellyfin.Api/Controllers/InstantMixController.cs
  71. 6 5
      Jellyfin.Api/Controllers/ItemsController.cs
  72. 2 1
      Jellyfin.Api/Controllers/LibraryStructureController.cs
  73. 10 12
      Jellyfin.Api/Controllers/LiveTvController.cs
  74. 2 2
      Jellyfin.Api/Controllers/MediaInfoController.cs
  75. 2 2
      Jellyfin.Api/Controllers/MoviesController.cs
  76. 8 132
      Jellyfin.Api/Controllers/MusicGenresController.cs
  77. 26 164
      Jellyfin.Api/Controllers/PersonsController.cs
  78. 2 1
      Jellyfin.Api/Controllers/PlaylistsController.cs
  79. 1 1
      Jellyfin.Api/Controllers/SessionController.cs
  80. 7 129
      Jellyfin.Api/Controllers/StudiosController.cs
  81. 125 0
      Jellyfin.Api/Controllers/SubtitleController.cs
  82. 3 0
      Jellyfin.Api/Controllers/SuggestionsController.cs
  83. 5 4
      Jellyfin.Api/Controllers/TrailersController.cs
  84. 5 4
      Jellyfin.Api/Controllers/TvShowsController.cs
  85. 18 14
      Jellyfin.Api/Controllers/UniversalAudioController.cs
  86. 27 0
      Jellyfin.Api/Controllers/UserController.cs
  87. 2 1
      Jellyfin.Api/Controllers/UserLibraryController.cs
  88. 2 1
      Jellyfin.Api/Controllers/YearsController.cs
  89. 13 0
      Jellyfin.Api/Helpers/ClaimHelpers.cs
  90. 2 3
      Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
  91. 2 2
      Jellyfin.Api/Helpers/MediaInfoHelper.cs
  92. 166 0
      Jellyfin.Api/Helpers/ProgressiveFileStream.cs
  93. 33 23
      Jellyfin.Api/Helpers/RequestHelpers.cs
  94. 2 2
      Jellyfin.Api/Helpers/SimilarItemsHelper.cs
  95. 1 1
      Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs
  96. 0 29
      Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinderProvider.cs
  97. 34 0
      Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs
  98. 12 0
      Jellyfin.Server.Implementations/Activity/ActivityManager.cs
  99. 16 4
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  100. 1 1
      Jellyfin.Server/Jellyfin.Server.csproj

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

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

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

@@ -63,6 +63,7 @@ jobs:
       sshEndpoint: repository
       sourceFolder: '$(Build.SourcesDirectory)/deployment/dist'
       contents: '**'
+      targetFolder: '/srv/repository/incoming/azure/$(Build.BuildNumber)/$(BuildConfiguration)'
 
 - job: OpenAPISpec
   dependsOn: Test
@@ -166,7 +167,7 @@ jobs:
     inputs:
       sshEndpoint: repository
       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
     displayName: 'Update Stable Repository'
@@ -175,7 +176,7 @@ jobs:
     inputs:
       sshEndpoint: repository
       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
   displayName: 'Publish NuGet packages'

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

@@ -487,7 +487,7 @@ namespace Emby.Dlna.ContentDirectory
                 User = user,
                 Recursive = true,
                 IsMissing = false,
-                ExcludeItemTypes = new[] { typeof(Book).Name },
+                ExcludeItemTypes = new[] { nameof(Book) },
                 IsFolder = isFolder,
                 MediaTypes = mediaTypes,
                 DtoOptions = GetDtoOptions()
@@ -556,7 +556,7 @@ namespace Emby.Dlna.ContentDirectory
                 Limit = limit,
                 StartIndex = startIndex,
                 IsVirtualItem = false,
-                ExcludeItemTypes = new[] { typeof(Book).Name },
+                ExcludeItemTypes = new[] { nameof(Book) },
                 IsPlaceHolder = false,
                 DtoOptions = GetDtoOptions()
             };
@@ -575,7 +575,7 @@ namespace Emby.Dlna.ContentDirectory
                 StartIndex = startIndex,
                 Limit = limit,
             };
-            query.IncludeItemTypes = new[] { typeof(LiveTvChannel).Name };
+            query.IncludeItemTypes = new[] { nameof(LiveTvChannel) };
 
             SetSorting(query, sort, false);
 
@@ -910,7 +910,7 @@ namespace Emby.Dlna.ContentDirectory
             query.Parent = parent;
             query.SetUser(user);
 
-            query.IncludeItemTypes = new[] { typeof(Series).Name };
+            query.IncludeItemTypes = new[] { nameof(Series) };
 
             var result = _libraryManager.GetItemsResult(query);
 
@@ -923,7 +923,7 @@ namespace Emby.Dlna.ContentDirectory
             query.Parent = parent;
             query.SetUser(user);
 
-            query.IncludeItemTypes = new[] { typeof(Movie).Name };
+            query.IncludeItemTypes = new[] { nameof(Movie) };
 
             var result = _libraryManager.GetItemsResult(query);
 
@@ -936,7 +936,7 @@ namespace Emby.Dlna.ContentDirectory
             // query.Parent = parent;
             query.SetUser(user);
 
-            query.IncludeItemTypes = new[] { typeof(BoxSet).Name };
+            query.IncludeItemTypes = new[] { nameof(BoxSet) };
 
             var result = _libraryManager.GetItemsResult(query);
 
@@ -949,7 +949,7 @@ namespace Emby.Dlna.ContentDirectory
             query.Parent = parent;
             query.SetUser(user);
 
-            query.IncludeItemTypes = new[] { typeof(MusicAlbum).Name };
+            query.IncludeItemTypes = new[] { nameof(MusicAlbum) };
 
             var result = _libraryManager.GetItemsResult(query);
 
@@ -962,7 +962,7 @@ namespace Emby.Dlna.ContentDirectory
             query.Parent = parent;
             query.SetUser(user);
 
-            query.IncludeItemTypes = new[] { typeof(Audio).Name };
+            query.IncludeItemTypes = new[] { nameof(Audio) };
 
             var result = _libraryManager.GetItemsResult(query);
 
@@ -975,7 +975,7 @@ namespace Emby.Dlna.ContentDirectory
             query.Parent = parent;
             query.SetUser(user);
             query.IsFavorite = true;
-            query.IncludeItemTypes = new[] { typeof(Audio).Name };
+            query.IncludeItemTypes = new[] { nameof(Audio) };
 
             var result = _libraryManager.GetItemsResult(query);
 
@@ -988,7 +988,7 @@ namespace Emby.Dlna.ContentDirectory
             query.Parent = parent;
             query.SetUser(user);
             query.IsFavorite = true;
-            query.IncludeItemTypes = new[] { typeof(Series).Name };
+            query.IncludeItemTypes = new[] { nameof(Series) };
 
             var result = _libraryManager.GetItemsResult(query);
 
@@ -1001,7 +1001,7 @@ namespace Emby.Dlna.ContentDirectory
             query.Parent = parent;
             query.SetUser(user);
             query.IsFavorite = true;
-            query.IncludeItemTypes = new[] { typeof(Episode).Name };
+            query.IncludeItemTypes = new[] { nameof(Episode) };
 
             var result = _libraryManager.GetItemsResult(query);
 
@@ -1014,7 +1014,7 @@ namespace Emby.Dlna.ContentDirectory
             query.Parent = parent;
             query.SetUser(user);
             query.IsFavorite = true;
-            query.IncludeItemTypes = new[] { typeof(Movie).Name };
+            query.IncludeItemTypes = new[] { nameof(Movie) };
 
             var result = _libraryManager.GetItemsResult(query);
 
@@ -1027,7 +1027,7 @@ namespace Emby.Dlna.ContentDirectory
             query.Parent = parent;
             query.SetUser(user);
             query.IsFavorite = true;
-            query.IncludeItemTypes = new[] { typeof(MusicAlbum).Name };
+            query.IncludeItemTypes = new[] { nameof(MusicAlbum) };
 
             var result = _libraryManager.GetItemsResult(query);
 
@@ -1181,7 +1181,7 @@ namespace Emby.Dlna.ContentDirectory
                 {
                     UserId = user.Id,
                     Limit = 50,
-                    IncludeItemTypes = new[] { typeof(Episode).Name },
+                    IncludeItemTypes = new[] { nameof(Episode) },
                     ParentId = parent == null ? Guid.Empty : parent.Id,
                     GroupItems = false
                 },
@@ -1215,7 +1215,7 @@ namespace Emby.Dlna.ContentDirectory
                 Recursive = true,
                 ParentId = parentId,
                 ArtistIds = new[] { item.Id },
-                IncludeItemTypes = new[] { typeof(MusicAlbum).Name },
+                IncludeItemTypes = new[] { nameof(MusicAlbum) },
                 Limit = limit,
                 StartIndex = startIndex,
                 DtoOptions = GetDtoOptions()
@@ -1259,7 +1259,7 @@ namespace Emby.Dlna.ContentDirectory
                 Recursive = true,
                 ParentId = parentId,
                 GenreIds = new[] { item.Id },
-                IncludeItemTypes = new[] { typeof(MusicAlbum).Name },
+                IncludeItemTypes = new[] { nameof(MusicAlbum) },
                 Limit = limit,
                 StartIndex = startIndex,
                 DtoOptions = GetDtoOptions()
@@ -1346,8 +1346,8 @@ namespace Emby.Dlna.ContentDirectory
             {
                 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;
                 }

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

@@ -123,7 +123,7 @@ namespace Emby.Dlna.Didl
         {
             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)
                 {
                     writer.WriteAttributeString(parts[0], parts[1], null, att.Value);

+ 3 - 3
Emby.Dlna/DlnaManager.cs

@@ -383,9 +383,9 @@ namespace Emby.Dlna
                     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))
                 {

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

@@ -168,7 +168,7 @@ namespace Emby.Dlna.Eventing
 
             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.Headers.TryAddWithoutValidation("NT", subscription.NotificationType);
             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()
         {
-            var addresses = await _appHost.GetLocalIpAddresses(CancellationToken.None).ConfigureAwait(false);
+            var addresses = await _appHost.GetLocalIpAddresses().ConfigureAwait(false);
 
             var udn = CreateUuid(_appHost.SystemId);
+            var descriptorUri = "/dlna/" + udn + "/description.xml";
 
             foreach (var address in addresses)
             {
@@ -279,7 +280,6 @@ namespace Emby.Dlna.Main
 
                 _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 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)
         {
-            _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);
 
@@ -339,7 +339,7 @@ namespace Emby.Dlna.PlayTo
             var startIndex = command.StartIndex ?? 0;
             if (startIndex > 0)
             {
-                items = items.Skip(startIndex).ToList();
+                items = items.GetRange(startIndex, items.Count - startIndex);
             }
 
             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);
                         using var subReader = reader.ReadSubtree();
                         await ParseFirstBodyChildAsync(subReader, result.Headers).ConfigureAwait(false);
+                        return result;
                     }
                     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)
         {
             CleanDateTimeResult result = new CleanDateTimeResult(name);
+            if (string.IsNullOrEmpty(name))
+            {
+                return result;
+            }
+
             var len = cleanDateTimeRegexes.Count;
             for (int i = 0; i < len; i++)
             {

+ 4 - 1
Emby.Notifications/NotificationEntryPoint.cs

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

+ 1 - 1
Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs

@@ -62,7 +62,7 @@ namespace Emby.Server.Implementations.AppBase
         }
 
         /// <inheritdoc />
-        public string VirtualDataPath { get; } = "%AppDataPath%";
+        public string VirtualDataPath => "%AppDataPath%";
 
         /// <summary>
         /// Gets the image cache path.

+ 32 - 56
Emby.Server.Implementations/ApplicationHost.cs

@@ -4,7 +4,6 @@ using System;
 using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Diagnostics;
-using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Net;
@@ -30,7 +29,6 @@ using Emby.Server.Implementations.Cryptography;
 using Emby.Server.Implementations.Data;
 using Emby.Server.Implementations.Devices;
 using Emby.Server.Implementations.Dto;
-using Emby.Server.Implementations.HttpServer;
 using Emby.Server.Implementations.HttpServer.Security;
 using Emby.Server.Implementations.IO;
 using Emby.Server.Implementations.Library;
@@ -339,7 +337,7 @@ namespace Emby.Server.Implementations
         /// Gets the email address for use within a comment section of a user agent field.
         /// Presently used to provide contact information to MusicBrainz service.
         /// </summary>
-        public string ApplicationUserAgentAddress { get; } = "team@jellyfin.org";
+        public string ApplicationUserAgentAddress => "team@jellyfin.org";
 
         /// <summary>
         /// Gets the current application name.
@@ -403,7 +401,7 @@ namespace Emby.Server.Implementations
         /// <summary>
         /// Resolves this instance.
         /// </summary>
-        /// <typeparam name="T">The type</typeparam>
+        /// <typeparam name="T">The type.</typeparam>
         /// <returns>``0.</returns>
         public T Resolve<T>() => ServiceProvider.GetService<T>();
 
@@ -993,62 +991,36 @@ namespace Emby.Server.Implementations
 
         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);
-            string metafile;
 
             foreach (var dir in directories)
             {
                 try
                 {
-                    metafile = Path.Combine(dir, "meta.json");
+                    var metafile = Path.Combine(dir, "meta.json");
                     if (File.Exists(metafile))
                     {
                         var manifest = _jsonSerializer.DeserializeFromFile<PluginManifest>(metafile);
 
                         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))
                         {
-                            version = new Version(0, 0, 0, 1);
+                            version = minimumVersion;
                         }
 
                         if (ApplicationVersion >= targetAbi)
                         {
                             // 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
@@ -1057,15 +1029,15 @@ namespace Emby.Server.Implementations
                         metafile = dir.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries)[^1];
 
                         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.
-                            versions.Add((ver, metafile, dir));
+                            versions.Add(new LocalPlugin(Guid.Empty, metafile, parsedVersion, dir));
                         }
                         else
                         {
                             // 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;
-            versions.Sort(VersionCompare);
+            versions.Sort(LocalPlugin.Compare);
             // Traverse backwards through the list.
             // The first item will be the latest version.
             for (int x = versions.Count - 1; x >= 0; x--)
             {
                 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;
                     continue;
                 }
@@ -1091,6 +1063,7 @@ namespace Emby.Server.Implementations
                 if (!string.IsNullOrEmpty(lastName) && cleanup)
                 {
                     // Attempt a cleanup of old folders.
+                    versions.RemoveAt(x);
                     try
                     {
                         Logger.LogDebug("Deleting {Path}", versions[x].Path);
@@ -1103,7 +1076,7 @@ namespace Emby.Server.Implementations
                 }
             }
 
-            return dllList;
+            return versions;
         }
 
         /// <summary>
@@ -1114,21 +1087,24 @@ namespace Emby.Server.Implementations
         {
             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;
+                    }
                 }
             }
 

+ 7 - 12
Emby.Server.Implementations/Channels/ChannelManager.cs

@@ -250,21 +250,16 @@ namespace Emby.Server.Implementations.Channels
             var all = channels;
             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)
             {
-                foreach (var item in returnItems)
+                foreach (var item in all)
                 {
                     RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).GetAwaiter().GetResult();
                 }
@@ -272,7 +267,7 @@ namespace Emby.Server.Implementations.Channels
 
             return new QueryResult<Channel>
             {
-                Items = returnItems,
+                Items = all,
                 TotalRecordCount = totalCount
             };
         }
@@ -543,7 +538,7 @@ namespace Emby.Server.Implementations.Channels
             return _libraryManager.GetItemIds(
                 new InternalItemsQuery
                 {
-                    IncludeItemTypes = new[] { typeof(Channel).Name },
+                    IncludeItemTypes = new[] { nameof(Channel) },
                     OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }
                 }).Select(i => GetChannelFeatures(i.ToString("N", CultureInfo.InvariantCulture))).ToArray();
         }

+ 1 - 1
Emby.Server.Implementations/Channels/ChannelPostScanTask.cs

@@ -51,7 +51,7 @@ namespace Emby.Server.Implementations.Channels
 
             var uninstalledChannels = _libraryManager.GetItemList(new InternalItemsQuery
             {
-                IncludeItemTypes = new[] { typeof(Channel).Name },
+                IncludeItemTypes = new[] { nameof(Channel) },
                 ExcludeItemIds = installedChannelIds.ToArray()
             });
 

+ 49 - 48
Emby.Server.Implementations/Data/SqliteItemRepository.cs

@@ -3914,7 +3914,7 @@ namespace Emby.Server.Implementations.Data
                 if (query.IsPlayed.HasValue)
                 {
                     // We should probably figure this out for all folders, but for right now, this is the only place where we need it
-                    if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], typeof(Series).Name, StringComparison.OrdinalIgnoreCase))
+                    if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], nameof(Series), StringComparison.OrdinalIgnoreCase))
                     {
                         if (query.IsPlayed.Value)
                         {
@@ -4755,29 +4755,29 @@ namespace Emby.Server.Implementations.Data
         {
             var list = new List<string>();
 
-            if (IsTypeInQuery(typeof(Person).Name, query))
+            if (IsTypeInQuery(nameof(Person), query))
             {
-                list.Add(typeof(Person).Name);
+                list.Add(nameof(Person));
             }
 
-            if (IsTypeInQuery(typeof(Genre).Name, query))
+            if (IsTypeInQuery(nameof(Genre), query))
             {
-                list.Add(typeof(Genre).Name);
+                list.Add(nameof(Genre));
             }
 
-            if (IsTypeInQuery(typeof(MusicGenre).Name, query))
+            if (IsTypeInQuery(nameof(MusicGenre), query))
             {
-                list.Add(typeof(MusicGenre).Name);
+                list.Add(nameof(MusicGenre));
             }
 
-            if (IsTypeInQuery(typeof(MusicArtist).Name, query))
+            if (IsTypeInQuery(nameof(MusicArtist), query))
             {
-                list.Add(typeof(MusicArtist).Name);
+                list.Add(nameof(MusicArtist));
             }
 
-            if (IsTypeInQuery(typeof(Studio).Name, query))
+            if (IsTypeInQuery(nameof(Studio), query))
             {
-                list.Add(typeof(Studio).Name);
+                list.Add(nameof(Studio));
             }
 
             return list;
@@ -4832,12 +4832,12 @@ namespace Emby.Server.Implementations.Data
 
             var types = new[]
             {
-                typeof(Episode).Name,
-                typeof(Video).Name,
-                typeof(Movie).Name,
-                typeof(MusicVideo).Name,
-                typeof(Series).Name,
-                typeof(Season).Name
+                nameof(Episode),
+                nameof(Video),
+                nameof(Movie),
+                nameof(MusicVideo),
+                nameof(Series),
+                nameof(Season)
             };
 
             if (types.Any(i => query.IncludeItemTypes.Contains(i, StringComparer.OrdinalIgnoreCase)))
@@ -5002,26 +5002,33 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
 
             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);
 
             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)
             {
-                commandText += " LIMIT " + query.Limit;
+                commandText.Append(" LIMIT ").Append(query.Limit);
             }
 
             using (var connection = GetConnection(true))
             {
                 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
                     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))
             {
                 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))
             {
-                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();
@@ -5107,10 +5108,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             if (queryPersonTypes.Count == 1)
             {
                 whereClauses.Add("PersonType=@PersonType");
-                if (statement != null)
-                {
-                    statement.TryBind("@PersonType", queryPersonTypes[0]);
-                }
+                statement?.TryBind("@PersonType", queryPersonTypes[0]);
             }
             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)
             {
                 whereClauses.Add("PersonType<>@PersonType");
-                if (statement != null)
-                {
-                    statement.TryBind("@PersonType", queryExcludePersonTypes[0]);
-                }
+                statement?.TryBind("@PersonType", queryExcludePersonTypes[0]);
             }
             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)
             {
                 whereClauses.Add("ListOrder<=@MaxListOrder");
-                if (statement != null)
-                {
-                    statement.TryBind("@MaxListOrder", query.MaxListOrder.Value);
-                }
+                statement?.TryBind("@MaxListOrder", query.MaxListOrder.Value);
             }
 
             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;
@@ -5420,6 +5420,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                 NameStartsWithOrGreater = query.NameStartsWithOrGreater,
                 Tags = query.Tags,
                 OfficialRatings = query.OfficialRatings,
+                StudioIds = query.StudioIds,
                 GenreIds = query.GenreIds,
                 Genres = query.Genres,
                 Years = query.Years,

+ 1 - 1
Emby.Server.Implementations/Dto/DtoService.cs

@@ -465,7 +465,7 @@ namespace Emby.Server.Implementations.Dto
             {
                 var parentAlbumIds = _libraryManager.GetItemIds(new InternalItemsQuery
                 {
-                    IncludeItemTypes = new[] { typeof(MusicAlbum).Name },
+                    IncludeItemTypes = new[] { nameof(MusicAlbum) },
                     Name = item.Album,
                     Limit = 1
                 });

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

@@ -1,6 +1,7 @@
 #pragma warning disable CS1591
 
 using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Net;
 using Microsoft.AspNetCore.Http;
 
@@ -19,12 +20,12 @@ namespace Emby.Server.Implementations.HttpServer.Security
         public AuthorizationInfo Authenticate(HttpRequest 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.");
             }

+ 76 - 69
Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs

@@ -36,8 +36,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
         public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext)
         {
             var auth = GetAuthorizationDictionary(requestContext);
-            var (authInfo, _) =
-                GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query);
+            var authInfo = GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query);
             return authInfo;
         }
 
@@ -49,19 +48,13 @@ namespace Emby.Server.Implementations.HttpServer.Security
         private AuthorizationInfo GetAuthorization(HttpContext 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;
             return authInfo;
         }
 
-        private (AuthorizationInfo authInfo, AuthenticationInfo originalAuthenticationInfo) GetAuthorizationInfoFromDictionary(
+        private AuthorizationInfo GetAuthorizationInfoFromDictionary(
             in Dictionary<string, string> auth,
             in IHeaderDictionary headers,
             in IQueryCollection queryString)
@@ -108,88 +101,102 @@ namespace Emby.Server.Implementations.HttpServer.Security
                 Device = device,
                 DeviceId = deviceId,
                 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;
+                        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>
@@ -267,7 +274,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
                 if (param.Length == 2)
                 {
                     var value = NormalizeValue(param[1].Trim(new[] { '"' }));
-                    result.Add(param[0], value);
+                    result[param[0]] = value;
                 }
             }
 

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

@@ -42,7 +42,7 @@ namespace Emby.Server.Implementations.Images
             // return _libraryManager.GetItemList(new InternalItemsQuery
             // {
             //    ArtistIds = new[] { item.Id },
-            //    IncludeItemTypes = new[] { typeof(MusicAlbum).Name },
+            //    IncludeItemTypes = new[] { nameof(MusicAlbum) },
             //    OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) },
             //    Limit = 4,
             //    Recursive = true,

+ 7 - 2
Emby.Server.Implementations/Images/GenreImageProvider.cs

@@ -42,7 +42,12 @@ namespace Emby.Server.Implementations.Images
             return _libraryManager.GetItemList(new InternalItemsQuery
             {
                 Genres = new[] { item.Name },
-                IncludeItemTypes = new[] { typeof(MusicAlbum).Name, typeof(MusicVideo).Name, typeof(Audio).Name },
+                IncludeItemTypes = new[]
+                {
+                    nameof(MusicAlbum),
+                    nameof(MusicVideo),
+                    nameof(Audio)
+                },
                 OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) },
                 Limit = 4,
                 Recursive = true,
@@ -77,7 +82,7 @@ namespace Emby.Server.Implementations.Images
             return _libraryManager.GetItemList(new InternalItemsQuery
             {
                 Genres = new[] { item.Name },
-                IncludeItemTypes = new[] { typeof(Series).Name, typeof(Movie).Name },
+                IncludeItemTypes = new[] { nameof(Series), nameof(Movie) },
                 OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) },
                 Limit = 4,
                 Recursive = true,

+ 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);
         }
 
+        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 />
         public bool IsVideoFile(string path)
         {

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

@@ -49,7 +49,7 @@ namespace Emby.Server.Implementations.Library
             var genres = item
                .GetRecursiveChildren(user, new InternalItemsQuery(user)
                {
-                   IncludeItemTypes = new[] { typeof(Audio).Name },
+                   IncludeItemTypes = new[] { nameof(Audio) },
                    DtoOptions = dtoOptions
                })
                .Cast<Audio>()
@@ -86,7 +86,7 @@ namespace Emby.Server.Implementations.Library
         {
             return _libraryManager.GetItemList(new InternalItemsQuery(user)
             {
-                IncludeItemTypes = new[] { typeof(Audio).Name },
+                IncludeItemTypes = new[] { nameof(Audio) },
 
                 GenreIds = genreIds.ToArray(),
 

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

@@ -87,61 +87,61 @@ namespace Emby.Server.Implementations.Library
             var excludeItemTypes = query.ExcludeItemTypes.ToList();
             var includeItemTypes = (query.IncludeItemTypes ?? Array.Empty<string>()).ToList();
 
-            excludeItemTypes.Add(typeof(Year).Name);
-            excludeItemTypes.Add(typeof(Folder).Name);
+            excludeItemTypes.Add(nameof(Year));
+            excludeItemTypes.Add(nameof(Folder));
 
             if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains("Genre", StringComparer.OrdinalIgnoreCase)))
             {
                 if (!query.IncludeMedia)
                 {
-                    AddIfMissing(includeItemTypes, typeof(Genre).Name);
-                    AddIfMissing(includeItemTypes, typeof(MusicGenre).Name);
+                    AddIfMissing(includeItemTypes, nameof(Genre));
+                    AddIfMissing(includeItemTypes, nameof(MusicGenre));
                 }
             }
             else
             {
-                AddIfMissing(excludeItemTypes, typeof(Genre).Name);
-                AddIfMissing(excludeItemTypes, typeof(MusicGenre).Name);
+                AddIfMissing(excludeItemTypes, nameof(Genre));
+                AddIfMissing(excludeItemTypes, nameof(MusicGenre));
             }
 
             if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains("People", StringComparer.OrdinalIgnoreCase) || includeItemTypes.Contains("Person", StringComparer.OrdinalIgnoreCase)))
             {
                 if (!query.IncludeMedia)
                 {
-                    AddIfMissing(includeItemTypes, typeof(Person).Name);
+                    AddIfMissing(includeItemTypes, nameof(Person));
                 }
             }
             else
             {
-                AddIfMissing(excludeItemTypes, typeof(Person).Name);
+                AddIfMissing(excludeItemTypes, nameof(Person));
             }
 
             if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains("Studio", StringComparer.OrdinalIgnoreCase)))
             {
                 if (!query.IncludeMedia)
                 {
-                    AddIfMissing(includeItemTypes, typeof(Studio).Name);
+                    AddIfMissing(includeItemTypes, nameof(Studio));
                 }
             }
             else
             {
-                AddIfMissing(excludeItemTypes, typeof(Studio).Name);
+                AddIfMissing(excludeItemTypes, nameof(Studio));
             }
 
             if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase)))
             {
                 if (!query.IncludeMedia)
                 {
-                    AddIfMissing(includeItemTypes, typeof(MusicArtist).Name);
+                    AddIfMissing(includeItemTypes, nameof(MusicArtist));
                 }
             }
             else
             {
-                AddIfMissing(excludeItemTypes, typeof(MusicArtist).Name);
+                AddIfMissing(excludeItemTypes, nameof(MusicArtist));
             }
 
-            AddIfMissing(excludeItemTypes, typeof(CollectionFolder).Name);
-            AddIfMissing(excludeItemTypes, typeof(Folder).Name);
+            AddIfMissing(excludeItemTypes, nameof(CollectionFolder));
+            AddIfMissing(excludeItemTypes, nameof(Folder));
             var mediaTypes = query.MediaTypes.ToList();
 
             if (includeItemTypes.Count > 0)

+ 1 - 1
Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs

@@ -81,7 +81,7 @@ namespace Emby.Server.Implementations.Library.Validators
 
             var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
             {
-                IncludeItemTypes = new[] { typeof(MusicArtist).Name },
+                IncludeItemTypes = new[] { nameof(MusicArtist) },
                 IsDeadArtist = true,
                 IsLocked = false
             }).Cast<MusicArtist>().ToList();

+ 1 - 1
Emby.Server.Implementations/Library/Validators/PeopleValidator.cs

@@ -91,7 +91,7 @@ namespace Emby.Server.Implementations.Library.Validators
 
             var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
             {
-                IncludeItemTypes = new[] { typeof(Person).Name },
+                IncludeItemTypes = new[] { nameof(Person) },
                 IsDeadPerson = true,
                 IsLocked = false
             });

+ 1 - 1
Emby.Server.Implementations/Library/Validators/StudiosValidator.cs

@@ -80,7 +80,7 @@ namespace Emby.Server.Implementations.Library.Validators
 
             var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
             {
-                IncludeItemTypes = new[] { typeof(Studio).Name },
+                IncludeItemTypes = new[] { nameof(Studio) },
                 IsDeadStudio = true,
                 IsLocked = false
             });

+ 7 - 7
Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs

@@ -1790,7 +1790,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
             {
                 var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery
                 {
-                    IncludeItemTypes = new[] { typeof(LiveTvProgram).Name },
+                    IncludeItemTypes = new[] { nameof(LiveTvProgram) },
                     Limit = 1,
                     ExternalId = timer.ProgramId,
                     DtoOptions = new DtoOptions(true)
@@ -2151,7 +2151,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
         {
             var query = new InternalItemsQuery
             {
-                IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name },
+                IncludeItemTypes = new string[] { nameof(LiveTvProgram) },
                 Limit = 1,
                 DtoOptions = new DtoOptions(true)
                 {
@@ -2370,7 +2370,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
 
             var query = new InternalItemsQuery
             {
-                IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name },
+                IncludeItemTypes = new string[] { nameof(LiveTvProgram) },
                 ExternalSeriesId = seriesTimer.SeriesId,
                 DtoOptions = new DtoOptions(true)
                 {
@@ -2405,7 +2405,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
                     channel = _libraryManager.GetItemList(
                         new InternalItemsQuery
                         {
-                            IncludeItemTypes = new string[] { typeof(LiveTvChannel).Name },
+                            IncludeItemTypes = new string[] { nameof(LiveTvChannel) },
                             ItemIds = new[] { parent.ChannelId },
                             DtoOptions = new DtoOptions()
                         }).FirstOrDefault() as LiveTvChannel;
@@ -2464,7 +2464,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
                     channel = _libraryManager.GetItemList(
                         new InternalItemsQuery
                         {
-                            IncludeItemTypes = new string[] { typeof(LiveTvChannel).Name },
+                            IncludeItemTypes = new string[] { nameof(LiveTvChannel) },
                             ItemIds = new[] { programInfo.ChannelId },
                             DtoOptions = new DtoOptions()
                         }).FirstOrDefault() as LiveTvChannel;
@@ -2529,7 +2529,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
                 var seriesIds = _libraryManager.GetItemIds(
                     new InternalItemsQuery
                     {
-                        IncludeItemTypes = new[] { typeof(Series).Name },
+                        IncludeItemTypes = new[] { nameof(Series) },
                         Name = program.Name
                     }).ToArray();
 
@@ -2542,7 +2542,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
                 {
                     var result = _libraryManager.GetItemIds(new InternalItemsQuery
                     {
-                        IncludeItemTypes = new[] { typeof(Episode).Name },
+                        IncludeItemTypes = new[] { nameof(Episode) },
                         ParentIndexNumber = program.SeasonNumber.Value,
                         IndexNumber = program.EpisodeNumber.Value,
                         AncestorIds = seriesIds,

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

@@ -15,6 +15,7 @@ using System.Threading.Tasks;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Cryptography;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.LiveTv;
@@ -33,17 +34,20 @@ namespace Emby.Server.Implementations.LiveTv.Listings
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1);
         private readonly IApplicationHost _appHost;
+        private readonly ICryptoProvider _cryptoProvider;
 
         public SchedulesDirect(
             ILogger<SchedulesDirect> logger,
             IJsonSerializer jsonSerializer,
             IHttpClientFactory httpClientFactory,
-            IApplicationHost appHost)
+            IApplicationHost appHost,
+            ICryptoProvider cryptoProvider)
         {
             _logger = logger;
             _jsonSerializer = jsonSerializer;
             _httpClientFactory = httpClientFactory;
             _appHost = appHost;
+            _cryptoProvider = cryptoProvider;
         }
 
         private string UserAgent => _appHost.ApplicationUserAgent;
@@ -642,7 +646,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             CancellationToken cancellationToken)
         {
             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);
             await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);

+ 4 - 4
Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs

@@ -159,7 +159,7 @@ namespace Emby.Server.Implementations.LiveTv
         {
             var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery
             {
-                IncludeItemTypes = new string[] { typeof(Series).Name },
+                IncludeItemTypes = new string[] { nameof(Series) },
                 Name = seriesName,
                 Limit = 1,
                 ImageTypes = new ImageType[] { ImageType.Thumb },
@@ -253,7 +253,7 @@ namespace Emby.Server.Implementations.LiveTv
         {
             var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery
             {
-                IncludeItemTypes = new string[] { typeof(Series).Name },
+                IncludeItemTypes = new string[] { nameof(Series) },
                 Name = seriesName,
                 Limit = 1,
                 ImageTypes = new ImageType[] { ImageType.Thumb },
@@ -296,7 +296,7 @@ namespace Emby.Server.Implementations.LiveTv
 
             var program = _libraryManager.GetItemList(new InternalItemsQuery
             {
-                IncludeItemTypes = new string[] { typeof(Series).Name },
+                IncludeItemTypes = new string[] { nameof(Series) },
                 Name = seriesName,
                 Limit = 1,
                 ImageTypes = new ImageType[] { ImageType.Primary },
@@ -307,7 +307,7 @@ namespace Emby.Server.Implementations.LiveTv
             {
                 program = _libraryManager.GetItemList(new InternalItemsQuery
                 {
-                    IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name },
+                    IncludeItemTypes = new string[] { nameof(LiveTvProgram) },
                     ExternalSeriesId = programSeriesId,
                     Limit = 1,
                     ImageTypes = new ImageType[] { ImageType.Primary },

+ 11 - 11
Emby.Server.Implementations/LiveTv/LiveTvManager.cs

@@ -187,7 +187,7 @@ namespace Emby.Server.Implementations.LiveTv
                 IsKids = query.IsKids,
                 IsSports = query.IsSports,
                 IsSeries = query.IsSeries,
-                IncludeItemTypes = new[] { typeof(LiveTvChannel).Name },
+                IncludeItemTypes = new[] { nameof(LiveTvChannel) },
                 TopParentIds = new[] { topFolder.Id },
                 IsFavorite = query.IsFavorite,
                 IsLiked = query.IsLiked,
@@ -808,7 +808,7 @@ namespace Emby.Server.Implementations.LiveTv
 
             var internalQuery = new InternalItemsQuery(user)
             {
-                IncludeItemTypes = new[] { typeof(LiveTvProgram).Name },
+                IncludeItemTypes = new[] { nameof(LiveTvProgram) },
                 MinEndDate = query.MinEndDate,
                 MinStartDate = query.MinStartDate,
                 MaxEndDate = query.MaxEndDate,
@@ -872,7 +872,7 @@ namespace Emby.Server.Implementations.LiveTv
 
             var internalQuery = new InternalItemsQuery(user)
             {
-                IncludeItemTypes = new[] { typeof(LiveTvProgram).Name },
+                IncludeItemTypes = new[] { nameof(LiveTvProgram) },
                 IsAiring = query.IsAiring,
                 HasAired = query.HasAired,
                 IsNews = query.IsNews,
@@ -1089,8 +1089,8 @@ namespace Emby.Server.Implementations.LiveTv
 
             if (cleanDatabase)
             {
-                CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { typeof(LiveTvChannel).Name }, progress, cancellationToken);
-                CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { typeof(LiveTvProgram).Name }, progress, cancellationToken);
+                CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { nameof(LiveTvChannel) }, progress, cancellationToken);
+                CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { nameof(LiveTvProgram) }, progress, cancellationToken);
             }
 
             var coreService = _services.OfType<EmbyTV.EmbyTV>().FirstOrDefault();
@@ -1181,7 +1181,7 @@ namespace Emby.Server.Implementations.LiveTv
 
                     var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery
                     {
-                        IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name },
+                        IncludeItemTypes = new string[] { nameof(LiveTvProgram) },
                         ChannelIds = new Guid[] { currentChannel.Id },
                         DtoOptions = new DtoOptions(true)
                     }).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
@@ -1346,11 +1346,11 @@ namespace Emby.Server.Implementations.LiveTv
             {
                 if (query.IsMovie.Value)
                 {
-                    includeItemTypes.Add(typeof(Movie).Name);
+                    includeItemTypes.Add(nameof(Movie));
                 }
                 else
                 {
-                    excludeItemTypes.Add(typeof(Movie).Name);
+                    excludeItemTypes.Add(nameof(Movie));
                 }
             }
 
@@ -1358,11 +1358,11 @@ namespace Emby.Server.Implementations.LiveTv
             {
                 if (query.IsSeries.Value)
                 {
-                    includeItemTypes.Add(typeof(Episode).Name);
+                    includeItemTypes.Add(nameof(Episode));
                 }
                 else
                 {
-                    excludeItemTypes.Add(typeof(Episode).Name);
+                    excludeItemTypes.Add(nameof(Episode));
                 }
             }
 
@@ -1883,7 +1883,7 @@ namespace Emby.Server.Implementations.LiveTv
 
             var programs = options.AddCurrentProgram ? _libraryManager.GetItemList(new InternalItemsQuery(user)
             {
-                IncludeItemTypes = new[] { typeof(LiveTvProgram).Name },
+                IncludeItemTypes = new[] { nameof(LiveTvProgram) },
                 ChannelIds = channelIds,
                 MaxStartDate = now,
                 MinEndDate = now,

+ 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);
         }
 
+        public string GetFilePath()
+        {
+            return TempFilePath;
+        }
+
         private Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
         {
             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);
 
-            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)
@@ -126,7 +128,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
 
         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.Controller;
 using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.LiveTv;
 using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.LiveTv.TunerHosts
@@ -30,12 +31,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
             _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.
-            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:";

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

@@ -55,7 +55,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
             var typeName = GetType().Name;
             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)
                 .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)
         {
             return Task.Run(async () =>

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

@@ -113,5 +113,7 @@
     "TasksChannelsCategory": "Internetové kanály",
     "TasksApplicationCategory": "Aplikace",
     "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",
     "TasksApplicationCategory": "Anwendung",
     "TasksLibraryCategory": "Bibliothek",
-    "TasksMaintenanceCategory": "Wartung"
+    "TasksMaintenanceCategory": "Wartung",
+    "TaskCleanActivityLogDescription": "Löscht Aktivitätsprotokolleinträge, die älter als das konfigurierte Alter sind.",
+    "TaskCleanActivityLog": "Aktivitätsprotokoll aufräumen"
 }

+ 2 - 0
Emby.Server.Implementations/Localization/Core/en-US.json

@@ -95,6 +95,8 @@
     "TasksLibraryCategory": "Library",
     "TasksApplicationCategory": "Application",
     "TasksChannelsCategory": "Internet Channels",
+    "TaskCleanActivityLog": "Clean Activity Log",
+    "TaskCleanActivityLogDescription": "Deletes activity log entries older than the configured age.",
     "TaskCleanCache": "Clean Cache Directory",
     "TaskCleanCacheDescription": "Deletes cache files no longer needed by the system.",
     "TaskRefreshChapterImages": "Extract Chapter Images",

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

@@ -77,7 +77,7 @@
     "SubtitleDownloadFailureFromForItem": "Fallo de descarga de subtítulos desde {0} para {1}",
     "Sync": "Sincronizar",
     "System": "Sistema",
-    "TvShows": "Programas de televisión",
+    "TvShows": "Series",
     "User": "Usuario",
     "UserCreatedWithName": "El usuario {0} ha sido creado",
     "UserDeletedWithName": "El usuario {0} ha sido borrado",
@@ -113,5 +113,7 @@
     "TaskRefreshChannels": "Actualizar canales",
     "TaskRefreshChannelsDescription": "Actualiza la información de los canales de internet.",
     "TaskDownloadMissingSubtitles": "Descargar los subtítulos que faltan",
-    "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten en el contenido de tus bibliotecas, basándose en la configuración de los metadatos."
+    "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten en el contenido de tus bibliotecas, basándose en la configuración de los metadatos.",
+    "TaskCleanActivityLogDescription": "Elimina todos los registros de actividad anteriores a la fecha configurada.",
+    "TaskCleanActivityLog": "Limpiar registro de actividad"
 }

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

@@ -113,5 +113,7 @@
     "TaskCleanCache": "Vider le répertoire cache",
     "TasksApplicationCategory": "Application",
     "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",
     "AuthenticationSucceededWithUserName": "{0} uspješno ovjerena",
     "Books": "Knjige",
-    "CameraImageUploadedFrom": "Nova fotografija sa kamere je uploadana iz {0}",
+    "CameraImageUploadedFrom": "Nova fotografija sa kamere je učitana iz {0}",
     "Channels": "Kanali",
     "ChapterNameValue": "Poglavlje {0}",
     "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",
     "Folders": "Mape",
     "Genres": "Žanrovi",
@@ -23,95 +23,97 @@
     "HeaderFavoriteShows": "Omiljene serije",
     "HeaderFavoriteSongs": "Omiljene pjesme",
     "HeaderLiveTV": "TV uživo",
-    "HeaderNextUp": "Sljedeće je",
+    "HeaderNextUp": "Slijedi",
     "HeaderRecordingGroups": "Grupa snimka",
-    "HomeVideos": "Kućni videi",
+    "HomeVideos": "Kućni video",
     "Inherit": "Naslijedi",
     "ItemAddedWithName": "{0} je dodano u biblioteku",
-    "ItemRemovedWithName": "{0} je uklonjen iz biblioteke",
+    "ItemRemovedWithName": "{0} je uklonjeno iz biblioteke",
     "LabelIpAddressValue": "IP adresa: {0}",
     "LabelRunningTimeValue": "Vrijeme rada: {0}",
     "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",
     "Movies": "Filmovi",
     "Music": "Glazba",
     "MusicVideos": "Glazbeni spotovi",
     "NameInstallFailed": "{0} neuspješnih instalacija",
     "NameSeasonNumber": "Sezona {0}",
-    "NameSeasonUnknown": "Nepoznata sezona",
+    "NameSeasonUnknown": "Sezona nepoznata",
     "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",
-    "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",
-    "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",
     "PluginInstalledWithName": "{0} je instalirano",
     "PluginUninstalledWithName": "{0} je deinstalirano",
     "PluginUpdatedWithName": "{0} je ažurirano",
-    "ProviderValue": "Pružitelj: {0}",
+    "ProviderValue": "Pružatelj: {0}",
     "ScheduledTaskFailedWithName": "{0} neuspjelo",
     "ScheduledTaskStartedWithName": "{0} pokrenuto",
-    "ServerNameNeedsToBeRestarted": "{0} treba biti ponovno pokrenuto",
+    "ServerNameNeedsToBeRestarted": "{0} treba ponovno pokrenuti",
     "Shows": "Serije",
     "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}",
-    "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",
     "User": "Korisnik",
-    "UserCreatedWithName": "Korisnik {0} je stvoren",
+    "UserCreatedWithName": "Korisnik {0} je kreiran",
     "UserDeletedWithName": "Korisnik {0} je obrisan",
-    "UserDownloadingItemWithValues": "{0} se preuzima {1}",
+    "UserDownloadingItemWithValues": "{0} preuzima {1}",
     "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}",
-    "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",
-    "ValueSpecialEpisodeName": "Specijal - {0}",
+    "ValueSpecialEpisodeName": "Posebno - {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",
     "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",
-    "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",
-    "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",
-    "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",
     "TasksApplicationCategory": "Applicazione",
     "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": "メディアライブラリをスキャンして新しいファイルを探し、メタデータをリフレッシュします。",
     "TaskRefreshLibrary": "メディアライブラリのスキャン",
     "TaskCleanCacheDescription": "不要なキャッシュを消去します。",
-    "TaskCleanCache": "キャッシュの掃除",
+    "TaskCleanCache": "キャッシュを消去",
     "TasksChannelsCategory": "ネットチャンネル",
     "TasksApplicationCategory": "アプリケーション",
     "TasksLibraryCategory": "ライブラリ",
@@ -112,5 +112,7 @@
     "TaskDownloadMissingSubtitlesDescription": "メタデータ構成に基づいて、欠落している字幕をインターネットで検索します。",
     "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネイルを作成します。",
     "TaskRefreshChapterImages": "チャプター画像を抽出する",
-    "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする"
+    "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする",
+    "TaskCleanActivityLogDescription": "設定された期間よりも古いアクティビティの履歴を削除します。",
+    "TaskCleanActivityLog": "アクティビティの履歴を消去"
 }

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

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

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

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

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

@@ -3,20 +3,20 @@
     "AppDeviceValues": "Aplikacija: {0}, Naprava: {1}",
     "Application": "Aplikacija",
     "Artists": "Izvajalci",
-    "AuthenticationSucceededWithUserName": "{0} preverjanje pristnosti uspešno",
+    "AuthenticationSucceededWithUserName": "{0} se je uspešno prijavil",
     "Books": "Knjige",
-    "CameraImageUploadedFrom": "Nova fotografija je bila naložena z {0}",
+    "CameraImageUploadedFrom": "Nova fotografija je bila naložena iz {0}",
     "Channels": "Kanali",
     "ChapterNameValue": "Poglavje {0}",
     "Collections": "Zbirke",
     "DeviceOfflineWithName": "{0} je prekinil povezavo",
     "DeviceOnlineWithName": "{0} je povezan",
-    "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave z {0}",
+    "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave iz {0}",
     "Favorites": "Priljubljeno",
     "Folders": "Mape",
     "Genres": "Zvrsti",
     "HeaderAlbumArtists": "Izvajalci albuma",
-    "HeaderContinueWatching": "Nadaljuj gledanje",
+    "HeaderContinueWatching": "Nadaljuj z ogledom",
     "HeaderFavoriteAlbums": "Priljubljeni albumi",
     "HeaderFavoriteArtists": "Priljubljeni izvajalci",
     "HeaderFavoriteEpisodes": "Priljubljene epizode",
@@ -32,23 +32,23 @@
     "LabelIpAddressValue": "IP naslov: {0}",
     "LabelRunningTimeValue": "Čas trajanja: {0}",
     "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",
-    "MixedContent": "Razne vsebine",
+    "MixedContent": "Mešane vsebine",
     "Movies": "Filmi",
     "Music": "Glasba",
     "MusicVideos": "Glasbeni videi",
     "NameInstallFailed": "{0} namestitev neuspešna",
     "NameSeasonNumber": "Sezona {0}",
-    "NameSeasonUnknown": "Season neznana",
+    "NameSeasonUnknown": "Neznana sezona",
     "NewVersionIsAvailable": "Nova različica Jellyfin strežnika je na voljo za prenos.",
     "NotificationOptionApplicationUpdateAvailable": "Posodobitev aplikacije je na voljo",
     "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",
     "NotificationOptionNewLibraryContent": "Nove vsebine dodane",
     "NotificationOptionPluginError": "Napaka dodatka",
@@ -56,41 +56,41 @@
     "NotificationOptionPluginUninstalled": "Dodatek odstranjen",
     "NotificationOptionPluginUpdateInstalled": "Posodobitev dodatka nameščena",
     "NotificationOptionServerRestartRequired": "Potreben je ponovni zagon strežnika",
-    "NotificationOptionTaskFailed": "Razporejena naloga neuspešna",
+    "NotificationOptionTaskFailed": "Načrtovano opravilo neuspešno",
     "NotificationOptionUserLockedOut": "Uporabnik zaklenjen",
     "NotificationOptionVideoPlayback": "Predvajanje videa se je začelo",
     "NotificationOptionVideoPlaybackStopped": "Predvajanje videa se je ustavilo",
     "Photos": "Fotografije",
     "Playlists": "Seznami predvajanja",
-    "Plugin": "Plugin",
+    "Plugin": "Dodatek",
     "PluginInstalledWithName": "{0} je bil nameščen",
     "PluginUninstalledWithName": "{0} je bil odstranjen",
     "PluginUpdatedWithName": "{0} je bil posodobljen",
-    "ProviderValue": "Provider: {0}",
+    "ProviderValue": "Ponudnik: {0}",
     "ScheduledTaskFailedWithName": "{0} ni uspelo",
     "ScheduledTaskStartedWithName": "{0} začeto",
     "ServerNameNeedsToBeRestarted": "{0} mora biti ponovno zagnan",
     "Shows": "Serije",
     "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}",
     "SubtitleDownloadFailureFromForItem": "Neuspešen prenos podnapisov iz {0} za {1}",
     "Sync": "Sinhroniziraj",
-    "System": "System",
+    "System": "Sistem",
     "TvShows": "TV serije",
-    "User": "User",
+    "User": "Uporabnik",
     "UserCreatedWithName": "Uporabnik {0} je bil ustvarjen",
     "UserDeletedWithName": "Uporabnik {0} je bil izbrisan",
     "UserDownloadingItemWithValues": "{0} prenaša {1}",
     "UserLockedOutWithName": "Uporabnik {0} je bil zaklenjen",
     "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",
     "UserPolicyUpdatedWithName": "Pravilnik uporabe je bil posodobljen za uporabnika {0}",
     "UserStartedPlayingItemWithValues": "{0} predvaja {1} na {2}",
     "UserStoppedPlayingItemWithValues": "{0} je nehal predvajati {1} na {2}",
     "ValueHasBeenAddedToLibrary": "{0} je bil dodan vaši knjižnici",
-    "ValueSpecialEpisodeName": "Poseben - {0}",
+    "ValueSpecialEpisodeName": "Posebna - {0}",
     "VersionNumber": "Različica {0}",
     "TaskDownloadMissingSubtitles": "Prenesi manjkajoče podnapise",
     "TaskRefreshChannelsDescription": "Osveži podatke spletnih kanalov.",
@@ -102,7 +102,7 @@
     "TaskRefreshPeopleDescription": "Osveži metapodatke za igralce in režiserje v vaši knjižnici.",
     "TaskRefreshPeople": "Osveži osebe",
     "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.",
     "TaskRefreshLibrary": "Preišči knjižnico predstavnosti",
     "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",
     "Channels": "Kanallar",
     "ChapterNameValue": "Bölüm {0}",
-    "Collections": "Koleksiyonlar",
+    "Collections": "Koleksiyon",
     "DeviceOfflineWithName": "{0} bağlantısı kesildi",
     "DeviceOnlineWithName": "{0} bağlı",
     "FailedLoginAttemptWithUserName": "{0} adresinden giriş başarısız oldu",
@@ -23,7 +23,7 @@
     "HeaderFavoriteShows": "Favori Diziler",
     "HeaderFavoriteSongs": "Favori Şarkılar",
     "HeaderLiveTV": "Canlı TV",
-    "HeaderNextUp": "Sonraki hafta",
+    "HeaderNextUp": "Gelecek Hafta",
     "HeaderRecordingGroups": "Kayıt Grupları",
     "HomeVideos": "Ev videoları",
     "Inherit": "Devral",
@@ -113,5 +113,7 @@
     "TaskRefreshLibrary": "Medya Kütüphanesini Tara",
     "TaskRefreshChapterImagesDescription": "Sahnelere ayrılmış videolar için küçük resimler oluştur.",
     "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",
     "AuthenticationSucceededWithUserName": "{0} xác thực thành cô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": "删除系统不再需要的缓存文件。",
     "TaskCleanCache": "清理缓存目录",
     "TasksApplicationCategory": "应用程序",
-    "TasksMaintenanceCategory": "维护"
+    "TasksMaintenanceCategory": "维护",
+    "TaskCleanActivityLog": "清理程序日志",
+    "TaskCleanActivityLogDescription": "删除早于设置时间的活动日志条目。"
 }

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

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

+ 4 - 4
Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs

@@ -703,7 +703,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
                 MaxRuntimeTicks = info.MaxRuntimeTicks
             };
 
-            if (info.Type.Equals(typeof(DailyTrigger).Name, StringComparison.OrdinalIgnoreCase))
+            if (info.Type.Equals(nameof(DailyTrigger), StringComparison.OrdinalIgnoreCase))
             {
                 if (!info.TimeOfDayTicks.HasValue)
                 {
@@ -717,7 +717,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
                 };
             }
 
-            if (info.Type.Equals(typeof(WeeklyTrigger).Name, StringComparison.OrdinalIgnoreCase))
+            if (info.Type.Equals(nameof(WeeklyTrigger), StringComparison.OrdinalIgnoreCase))
             {
                 if (!info.TimeOfDayTicks.HasValue)
                 {
@@ -737,7 +737,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
                 };
             }
 
-            if (info.Type.Equals(typeof(IntervalTrigger).Name, StringComparison.OrdinalIgnoreCase))
+            if (info.Type.Equals(nameof(IntervalTrigger), StringComparison.OrdinalIgnoreCase))
             {
                 if (!info.IntervalTicks.HasValue)
                 {
@@ -751,7 +751,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
                 };
             }
 
-            if (info.Type.Equals(typeof(StartupTrigger).Name, StringComparison.OrdinalIgnoreCase))
+            if (info.Type.Equals(nameof(StartupTrigger), StringComparison.OrdinalIgnoreCase))
             {
                 return new StartupTrigger();
             }

+ 78 - 0
Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs

@@ -0,0 +1,78 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks
+{
+    /// <summary>
+    /// Deletes old activity log entries.
+    /// </summary>
+    public class CleanActivityLogTask : IScheduledTask, IConfigurableScheduledTask
+    {
+        private readonly ILocalizationManager _localization;
+        private readonly IActivityManager _activityManager;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="CleanActivityLogTask"/> class.
+        /// </summary>
+        /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+        /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        public CleanActivityLogTask(
+            ILocalizationManager localization,
+            IActivityManager activityManager,
+            IServerConfigurationManager serverConfigurationManager)
+        {
+            _localization = localization;
+            _activityManager = activityManager;
+            _serverConfigurationManager = serverConfigurationManager;
+        }
+
+        /// <inheritdoc />
+        public string Name => _localization.GetLocalizedString("TaskCleanActivityLog");
+
+        /// <inheritdoc />
+        public string Key => "CleanActivityLog";
+
+        /// <inheritdoc />
+        public string Description => _localization.GetLocalizedString("TaskCleanActivityLogDescription");
+
+        /// <inheritdoc />
+        public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
+
+        /// <inheritdoc />
+        public bool IsHidden => false;
+
+        /// <inheritdoc />
+        public bool IsEnabled => true;
+
+        /// <inheritdoc />
+        public bool IsLogged => true;
+
+        /// <inheritdoc />
+        public Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
+        {
+            var retentionDays = _serverConfigurationManager.Configuration.ActivityLogRetentionDays;
+            if (!retentionDays.HasValue || retentionDays <= 0)
+            {
+                throw new Exception($"Activity Log Retention days must be at least 0. Currently: {retentionDays}");
+            }
+
+            var startDate = DateTime.UtcNow.AddDays(retentionDays.Value * -1);
+            return _activityManager.CleanAsync(startDate);
+        }
+
+        /// <inheritdoc />
+        public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+        {
+            return Enumerable.Empty<TaskTriggerInfo>();
+        }
+    }
+}

+ 1 - 1
Emby.Server.Implementations/ServerApplicationPaths.cs

@@ -104,6 +104,6 @@ namespace Emby.Server.Implementations
         public string InternalMetadataPath { get; set; }
 
         /// <inheritdoc />
-        public string VirtualInternalMetadataPath { get; } = "%MetadataPath%";
+        public string VirtualInternalMetadataPath => "%MetadataPath%";
     }
 }

+ 2 - 2
Emby.Server.Implementations/TV/TVSeriesManager.cs

@@ -121,7 +121,7 @@ namespace Emby.Server.Implementations.TV
                 .GetItemList(
                     new InternalItemsQuery(user)
                     {
-                        IncludeItemTypes = new[] { typeof(Episode).Name },
+                        IncludeItemTypes = new[] { nameof(Episode) },
                         OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.DatePlayed, SortOrder.Descending) },
                         SeriesPresentationUniqueKey = presentationUniqueKey,
                         Limit = limit,
@@ -214,7 +214,7 @@ namespace Emby.Server.Implementations.TV
                 {
                     AncestorWithPresentationUniqueKey = null,
                     SeriesPresentationUniqueKey = seriesKey,
-                    IncludeItemTypes = new[] { typeof(Episode).Name },
+                    IncludeItemTypes = new[] { nameof(Episode) },
                     OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) },
                     Limit = 1,
                     IsPlayed = false,

+ 24 - 52
Emby.Server.Implementations/Updates/InstallationManager.cs

@@ -10,19 +10,21 @@ using System.Runtime.Serialization;
 using System.Security.Cryptography;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Data.Events;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Common.Updates;
-using MediaBrowser.Common.System;
+using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Updates;
 using Microsoft.Extensions.Logging;
-using MediaBrowser.Model.System;
 
 namespace Emby.Server.Implementations.Updates
 {
@@ -36,6 +38,7 @@ namespace Emby.Server.Implementations.Updates
         /// </summary>
         private readonly ILogger<InstallationManager> _logger;
         private readonly IApplicationPaths _appPaths;
+        private readonly IEventManager _eventManager;
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly IJsonSerializer _jsonSerializer;
         private readonly IServerConfigurationManager _config;
@@ -45,7 +48,7 @@ namespace Emby.Server.Implementations.Updates
         /// Gets the application host.
         /// </summary>
         /// <value>The application host.</value>
-        private readonly IApplicationHost _applicationHost;
+        private readonly IServerApplicationHost _applicationHost;
 
         private readonly IZipClient _zipClient;
 
@@ -63,25 +66,22 @@ namespace Emby.Server.Implementations.Updates
 
         public InstallationManager(
             ILogger<InstallationManager> logger,
-            IApplicationHost appHost,
+            IServerApplicationHost appHost,
             IApplicationPaths appPaths,
+            IEventManager eventManager,
             IHttpClientFactory httpClientFactory,
             IJsonSerializer jsonSerializer,
             IServerConfigurationManager config,
             IFileSystem fileSystem,
             IZipClient zipClient)
         {
-            if (logger == null)
-            {
-                throw new ArgumentNullException(nameof(logger));
-            }
-
             _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>();
             _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>();
 
             _logger = logger;
             _applicationHost = appHost;
             _appPaths = appPaths;
+            _eventManager = eventManager;
             _httpClientFactory = httpClientFactory;
             _jsonSerializer = jsonSerializer;
             _config = config;
@@ -89,27 +89,6 @@ namespace Emby.Server.Implementations.Updates
             _zipClient = zipClient;
         }
 
-        /// <inheritdoc />
-        public event EventHandler<InstallationInfo> PackageInstalling;
-
-        /// <inheritdoc />
-        public event EventHandler<InstallationInfo> PackageInstallationCompleted;
-
-        /// <inheritdoc />
-        public event EventHandler<InstallationFailedEventArgs> PackageInstallationFailed;
-
-        /// <inheritdoc />
-        public event EventHandler<InstallationInfo> PackageInstallationCancelled;
-
-        /// <inheritdoc />
-        public event EventHandler<IPlugin> PluginUninstalled;
-
-        /// <inheritdoc />
-        public event EventHandler<InstallationInfo> PluginUpdated;
-
-        /// <inheritdoc />
-        public event EventHandler<InstallationInfo> PluginInstalled;
-
         /// <inheritdoc />
         public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal;
 
@@ -237,7 +216,8 @@ namespace Emby.Server.Implementations.Updates
 
         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 version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version);
@@ -268,11 +248,11 @@ namespace Emby.Server.Implementations.Updates
 
             var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token).Token;
 
-            PackageInstalling?.Invoke(this, package);
+            await _eventManager.PublishAsync(new PluginInstallingEventArgs(package)).ConfigureAwait(false);
 
             try
             {
-                await InstallPackageInternal(package, linkedToken).ConfigureAwait(false);
+                var isUpdate = await InstallPackageInternal(package, linkedToken).ConfigureAwait(false);
 
                 lock (_currentInstallationsLock)
                 {
@@ -280,8 +260,11 @@ namespace Emby.Server.Implementations.Updates
                 }
 
                 _completedInstallationsInternal.Add(package);
+                await _eventManager.PublishAsync(isUpdate
+                    ? (GenericEventArgs<InstallationInfo>)new PluginUpdatedEventArgs(package)
+                    : new PluginInstalledEventArgs(package)).ConfigureAwait(false);
 
-                PackageInstallationCompleted?.Invoke(this, package);
+                _applicationHost.NotifyPendingRestart();
             }
             catch (OperationCanceledException)
             {
@@ -292,7 +275,7 @@ namespace Emby.Server.Implementations.Updates
 
                 _logger.LogInformation("Package installation cancelled: {0} {1}", package.Name, package.Version);
 
-                PackageInstallationCancelled?.Invoke(this, package);
+                await _eventManager.PublishAsync(new PluginInstallationCancelledEventArgs(package)).ConfigureAwait(false);
 
                 throw;
             }
@@ -305,11 +288,11 @@ namespace Emby.Server.Implementations.Updates
                     _currentInstallations.Remove(tuple);
                 }
 
-                PackageInstallationFailed?.Invoke(this, new InstallationFailedEventArgs
+                await _eventManager.PublishAsync(new InstallationFailedEventArgs
                 {
                     InstallationInfo = package,
                     Exception = ex
-                });
+                }).ConfigureAwait(false);
 
                 throw;
             }
@@ -326,7 +309,7 @@ namespace Emby.Server.Implementations.Updates
         /// <param name="package">The package.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns><see cref="Task" />.</returns>
-        private async Task InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
+        private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
         {
             // Set last update time if we were installed before
             IPlugin plugin = _applicationHost.Plugins.FirstOrDefault(p => p.Id == package.Guid)
@@ -336,20 +319,9 @@ namespace Emby.Server.Implementations.Updates
             await PerformPackageInstallation(package, cancellationToken).ConfigureAwait(false);
 
             // Do plugin-specific processing
-            if (plugin == null)
-            {
-                _logger.LogInformation("New plugin installed: {0} {1}", package.Name, package.Version);
+            _logger.LogInformation(plugin == null ? "New plugin installed: {0} {1}" : "Plugin updated: {0} {1}", package.Name, package.Version);
 
-                PluginInstalled?.Invoke(this, package);
-            }
-            else
-            {
-                _logger.LogInformation("Plugin updated: {0} {1}", package.Name, package.Version);
-
-                PluginUpdated?.Invoke(this, package);
-            }
-
-            _applicationHost.NotifyPendingRestart();
+            return plugin != null;
         }
 
         private async Task PerformPackageInstallation(InstallationInfo package, CancellationToken cancellationToken)
@@ -467,7 +439,7 @@ namespace Emby.Server.Implementations.Updates
                 _config.SaveConfiguration();
             }
 
-            PluginUninstalled?.Invoke(this, plugin);
+            _eventManager.Publish(new PluginUninstalledEventArgs(plugin));
 
             _applicationHost.NotifyPendingRestart();
         }

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

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

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

@@ -1,10 +1,10 @@
 using System.Globalization;
-using System.Security.Authentication;
 using System.Security.Claims;
 using System.Text.Encodings.Web;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Net;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.Extensions.Logging;
@@ -43,24 +43,23 @@ namespace Jellyfin.Api.Auth
             try
             {
                 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[]
                 {
-                    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.DeviceId, authorizationInfo.DeviceId),
                     new Claim(InternalClaimTypes.Device, authorizationInfo.Device),
                     new Claim(InternalClaimTypes.Client, authorizationInfo.Client),
                     new Claim(InternalClaimTypes.Version, authorizationInfo.Version),
                     new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
+                    new Claim(InternalClaimTypes.IsApiKey, authorizationInfo.IsApiKey.ToString(CultureInfo.InvariantCulture))
                 };
 
                 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)
         {
             var validated = ValidateClaims(context.User);
-            if (!validated)
+            if (validated)
+            {
+                context.Succeed(requirement);
+            }
+            else
             {
                 context.Fail();
-                return Task.CompletedTask;
             }
 
-            context.Succeed(requirement);
             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)
         {
             var validated = ValidateClaims(context.User, ignoreSchedule: true);
-            if (!validated)
+            if (validated)
+            {
+                context.Succeed(requirement);
+            }
+            else
             {
                 context.Fail();
-                return Task.CompletedTask;
             }
 
-            context.Succeed(requirement);
             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)
         {
             var validated = ValidateClaims(context.User, localAccessOnly: true);
-            if (!validated)
+            if (validated)
             {
-                context.Fail();
+                context.Succeed(requirement);
             }
             else
             {
-                context.Succeed(requirement);
+                context.Fail();
             }
 
             return Task.CompletedTask;

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

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

+ 11 - 10
Jellyfin.Api/Controllers/ArtistsController.cs

@@ -4,6 +4,7 @@ using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -90,7 +91,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery] ItemFilter[] filters,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] string? mediaTypes,
             [FromQuery] string? genres,
@@ -100,7 +101,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,
@@ -146,9 +147,9 @@ namespace Jellyfin.Api.Controllers
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 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),
                 StudioIds = RequestHelpers.GetGuids(studioIds),
                 Person = person,
@@ -299,7 +300,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery] ItemFilter[] filters,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] string? mediaTypes,
             [FromQuery] string? genres,
@@ -309,7 +310,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,
@@ -355,9 +356,9 @@ namespace Jellyfin.Api.Controllers
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 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),
                 StudioIds = RequestHelpers.GetGuids(studioIds),
                 Person = person,

+ 3 - 2
Jellyfin.Api/Controllers/ChannelsController.cs

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -121,7 +122,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] string? sortOrder,
-            [FromQuery] ItemFilter[] filters,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] string? sortBy,
             [FromQuery] string? fields)
         {
@@ -196,7 +197,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] Guid? userId,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
-            [FromQuery] ItemFilter[] filters,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] string? fields,
             [FromQuery] string? channelIds)
         {

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

@@ -83,14 +83,14 @@ namespace Jellyfin.Api.Controllers
         /// Adds items to a collection.
         /// </summary>
         /// <param name="collectionId">The collection id.</param>
-        /// <param name="itemIds">Item ids, comma delimited.</param>
+        /// <param name="ids">Item ids, comma delimited.</param>
         /// <response code="204">Items added to collection.</response>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("{collectionId}/Items")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string itemIds)
+        public async Task<ActionResult> AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids)
         {
-            await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(itemIds)).ConfigureAwait(true);
+            await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(true);
             return NoContent();
         }
 
@@ -98,14 +98,14 @@ namespace Jellyfin.Api.Controllers
         /// Removes items from a collection.
         /// </summary>
         /// <param name="collectionId">The collection id.</param>
-        /// <param name="itemIds">Item ids, comma delimited.</param>
+        /// <param name="ids">Item ids, comma delimited.</param>
         /// <response code="204">Items removed from collection.</response>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpDelete("{collectionId}/Items")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string itemIds)
+        public async Task<ActionResult> RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids)
         {
-            await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(itemIds)).ConfigureAwait(false);
+            await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(false);
             return NoContent();
         }
     }

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

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

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

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

@@ -77,6 +77,7 @@ namespace Jellyfin.Api.Controllers
         /// Gets Dlna media receiver registrar xml.
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Dlna media receiver registrar xml returned.</response>
         /// <returns>Dlna media receiver registrar xml.</returns>
         [HttpGet("{serverId}/MediaReceiverRegistrar")]
         [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
@@ -94,6 +95,7 @@ namespace Jellyfin.Api.Controllers
         /// Gets Dlna media receiver registrar xml.
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Dlna media receiver registrar xml returned.</response>
         /// <returns>Dlna media receiver registrar xml.</returns>
         [HttpGet("{serverId}/ConnectionManager")]
         [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
@@ -111,8 +113,12 @@ namespace Jellyfin.Api.Controllers
         /// Process a content directory control request.
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Control response.</returns>
         [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)
         {
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
@@ -122,8 +128,12 @@ namespace Jellyfin.Api.Controllers
         /// Process a connection manager control request.
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Control response.</returns>
         [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)
         {
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
@@ -133,8 +143,12 @@ namespace Jellyfin.Api.Controllers
         /// Process a media receiver registrar control request.
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Control response.</returns>
         [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)
         {
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
@@ -144,11 +158,15 @@ namespace Jellyfin.Api.Controllers
         /// Processes an event subscription request.
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
         {
             return ProcessEventRequest(_mediaReceiverRegistrar);
@@ -158,11 +176,15 @@ namespace Jellyfin.Api.Controllers
         /// Processes an event subscription request.
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/ContentDirectory/Events")]
         [HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
         {
             return ProcessEventRequest(_contentDirectory);
@@ -172,11 +194,15 @@ namespace Jellyfin.Api.Controllers
         /// Processes an event subscription request.
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/ConnectionManager/Events")]
         [HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
         {
             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="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
         /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
         /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
         /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
         /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
@@ -351,6 +352,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? breakOnNonKeyFrames,
             [FromQuery] int? audioSampleRate,
             [FromQuery] int? maxAudioBitDepth,
+            [FromQuery] int? maxStreamingBitrate,
             [FromQuery] int? audioBitRate,
             [FromQuery] int? audioChannels,
             [FromQuery] int? maxAudioChannels,
@@ -403,7 +405,7 @@ namespace Jellyfin.Api.Controllers
                 BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
                 AudioSampleRate = audioSampleRate,
                 MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
+                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
                 MaxAudioBitDepth = maxAudioBitDepth,
                 AudioChannels = audioChannels,
                 Profile = profile,
@@ -623,6 +625,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
         /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
         /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
         /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
         /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
         /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
@@ -677,6 +680,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? breakOnNonKeyFrames,
             [FromQuery] int? audioSampleRate,
             [FromQuery] int? maxAudioBitDepth,
+            [FromQuery] int? maxStreamingBitrate,
             [FromQuery] int? audioBitRate,
             [FromQuery] int? audioChannels,
             [FromQuery] int? maxAudioChannels,
@@ -729,7 +733,7 @@ namespace Jellyfin.Api.Controllers
                 BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
                 AudioSampleRate = audioSampleRate,
                 MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
+                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
                 MaxAudioBitDepth = maxAudioBitDepth,
                 AudioChannels = audioChannels,
                 Profile = profile,
@@ -959,6 +963,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
         /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
         /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
         /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
         /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
         /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
@@ -1017,6 +1022,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? breakOnNonKeyFrames,
             [FromQuery] int? audioSampleRate,
             [FromQuery] int? maxAudioBitDepth,
+            [FromQuery] int? maxStreamingBitrate,
             [FromQuery] int? audioBitRate,
             [FromQuery] int? audioChannels,
             [FromQuery] int? maxAudioChannels,
@@ -1069,7 +1075,7 @@ namespace Jellyfin.Api.Controllers
                 BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
                 AudioSampleRate = audioSampleRate,
                 MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
+                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
                 MaxAudioBitDepth = maxAudioBitDepth,
                 AudioChannels = audioChannels,
                 Profile = profile,

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

@@ -1,10 +1,10 @@
 using System;
 using System.ComponentModel.DataAnnotations;
-using System.Globalization;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -48,7 +48,6 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Gets all genres from a given item, folder, or the entire library.
         /// </summary>
-        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
         /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
         /// <param name="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">The search term.</param>
@@ -56,22 +55,9 @@ namespace Jellyfin.Api.Controllers
         /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
         /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
         /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
         /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
-        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
-        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
-        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
-        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
-        /// <param name="enableUserData">Optional, include user data.</param>
         /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
         /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
-        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
         /// <param name="userId">User id.</param>
         /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
         /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -83,7 +69,6 @@ namespace Jellyfin.Api.Controllers
         [HttpGet]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetGenres(
-            [FromQuery] double? minCommunityRating,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
@@ -91,22 +76,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
-            [FromQuery] string? genres,
-            [FromQuery] string? genreIds,
-            [FromQuery] string? officialRatings,
-            [FromQuery] string? tags,
-            [FromQuery] string? years,
-            [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] Guid? userId,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
@@ -117,42 +89,22 @@ namespace Jellyfin.Api.Controllers
             var dtoOptions = new DtoOptions()
                 .AddItemFields(fields)
                 .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)
             {
                 ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
                 IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
-                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
                 StartIndex = startIndex,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, '|', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds),
-                StudioIds = RequestHelpers.GetGuids(studioIds),
-                Person = person,
-                PersonIds = RequestHelpers.GetGuids(personIds),
-                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
-                Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(),
-                MinCommunityRating = minCommunityRating,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
                 EnableTotalRecordCount = enableTotalRecordCount
@@ -170,87 +122,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>

+ 10 - 9
Jellyfin.Api/Controllers/InstantMixController.cs

@@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -72,7 +73,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -109,7 +110,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
             var album = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -146,7 +147,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
             var playlist = (Playlist)_libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -183,7 +184,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
@@ -219,7 +220,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -256,7 +257,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -293,7 +294,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -316,9 +317,9 @@ namespace Jellyfin.Api.Controllers
                 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);

+ 6 - 5
Jellyfin.Api/Controllers/ItemsController.cs

@@ -5,6 +5,7 @@ using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -159,7 +160,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isHd,
             [FromQuery] bool? is4K,
             [FromQuery] string? locationTypes,
-            [FromQuery] LocationType[] excludeLocationTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
             [FromQuery] bool? isMissing,
             [FromQuery] bool? isUnaired,
             [FromQuery] double? minCommunityRating,
@@ -182,10 +183,10 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery] ItemFilter[] filters,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] string? mediaTypes,
-            [FromQuery] ImageType[] imageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
             [FromQuery] string? sortBy,
             [FromQuery] bool? isPlayed,
             [FromQuery] string? genres,
@@ -194,7 +195,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,
@@ -536,7 +537,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? mediaTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] bool enableTotalRecordCount = true,

+ 2 - 1
Jellyfin.Api/Controllers/LibraryStructureController.cs

@@ -7,6 +7,7 @@ using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.LibraryStructureDto;
 using MediaBrowser.Common.Progress;
 using MediaBrowser.Controller;
@@ -75,7 +76,7 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult> AddVirtualFolder(
             [FromQuery] string? name,
             [FromQuery] string? collectionType,
-            [FromQuery] string[] paths,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths,
             [FromBody] AddVirtualFolderDto? libraryOptionsDto,
             [FromQuery] bool refreshLibrary = false)
         {

+ 10 - 12
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -14,6 +14,7 @@ using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.LiveTvDtos;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Common;
@@ -146,7 +147,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isDisliked,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] string? sortBy,
@@ -263,7 +264,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? seriesTimerId,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? isMovie,
@@ -350,7 +351,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? seriesTimerId,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool enableTotalRecordCount = true)
@@ -561,7 +562,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? genreIds,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] string? seriesTimerId,
             [FromQuery] Guid? librarySeriesId,
@@ -592,7 +593,7 @@ namespace Jellyfin.Api.Controllers
                 IsKids = isKids,
                 IsSports = isSports,
                 SeriesTimerId = seriesTimerId,
-                Genres = RequestHelpers.Split(genres, ',', true),
+                Genres = RequestHelpers.Split(genres, '|', true),
                 GenreIds = RequestHelpers.GetGuids(genreIds)
             };
 
@@ -648,7 +649,7 @@ namespace Jellyfin.Api.Controllers
                 IsKids = body.IsKids,
                 IsSports = body.IsSports,
                 SeriesTimerId = body.SeriesTimerId,
-                Genres = RequestHelpers.Split(body.Genres, ',', true),
+                Genres = RequestHelpers.Split(body.Genres, '|', true),
                 GenreIds = RequestHelpers.GetGuids(body.GenreIds)
             };
 
@@ -705,7 +706,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isSports,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? genreIds,
             [FromQuery] string? fields,
             [FromQuery] bool? enableUserData,
@@ -1220,11 +1221,8 @@ namespace Jellyfin.Api.Controllers
                 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()

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

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

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

@@ -85,8 +85,8 @@ namespace Jellyfin.Api.Controllers
                 IncludeItemTypes = new[]
                 {
                     nameof(Movie),
-                    // typeof(Trailer).Name,
-                    // typeof(LiveTvProgram).Name
+                    // nameof(Trailer),
+                    // nameof(LiveTvProgram)
                 },
                 // IsMovie = true
                 OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),

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

@@ -1,10 +1,10 @@
 using System;
 using System.ComponentModel.DataAnnotations;
-using System.Globalization;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -48,7 +48,6 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Gets all music genres from a given item, folder, or the entire library.
         /// </summary>
-        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
         /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
         /// <param name="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">The search term.</param>
@@ -56,22 +55,9 @@ namespace Jellyfin.Api.Controllers
         /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
         /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
         /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
         /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
-        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
-        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
-        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
-        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
-        /// <param name="enableUserData">Optional, include user data.</param>
         /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
         /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
-        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
         /// <param name="userId">User id.</param>
         /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
         /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -81,8 +67,8 @@ namespace Jellyfin.Api.Controllers
         /// <response code="200">Music genres returned.</response>
         /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns>
         [HttpGet]
+        [Obsolete("Use GetGenres instead")]
         public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres(
-            [FromQuery] double? minCommunityRating,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
@@ -90,22 +76,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
-            [FromQuery] string? genres,
-            [FromQuery] string? genreIds,
-            [FromQuery] string? officialRatings,
-            [FromQuery] string? tags,
-            [FromQuery] string? years,
-            [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] Guid? userId,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
@@ -116,42 +89,22 @@ namespace Jellyfin.Api.Controllers
             var dtoOptions = new DtoOptions()
                 .AddItemFields(fields)
                 .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)
             {
                 ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
                 IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
-                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
                 StartIndex = startIndex,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, '|', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds),
-                StudioIds = RequestHelpers.GetGuids(studioIds),
-                Person = person,
-                PersonIds = RequestHelpers.GetGuids(personIds),
-                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
-                Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(),
-                MinCommunityRating = minCommunityRating,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
                 EnableTotalRecordCount = enableTotalRecordCount
@@ -169,87 +122,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 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>

+ 26 - 164
Jellyfin.Api/Controllers/PersonsController.cs

@@ -1,10 +1,10 @@
 using System;
 using System.ComponentModel.DataAnnotations;
-using System.Globalization;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -27,6 +27,7 @@ namespace Jellyfin.Api.Controllers
         private readonly ILibraryManager _libraryManager;
         private readonly IDtoService _dtoService;
         private readonly IUserManager _userManager;
+        private readonly IUserDataManager _userDataManager;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="PersonsController"/> class.
@@ -34,84 +35,53 @@ namespace Jellyfin.Api.Controllers
         /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
         /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
         /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
         public PersonsController(
             ILibraryManager libraryManager,
             IDtoService dtoService,
-            IUserManager userManager)
+            IUserManager userManager,
+            IUserDataManager userDataManager)
         {
             _libraryManager = libraryManager;
             _dtoService = dtoService;
             _userManager = userManager;
+            _userDataManager = userDataManager;
         }
 
         /// <summary>
-        /// Gets all persons from a given item, folder, or the entire library.
+        /// Gets all persons.
         /// </summary>
-        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
         /// <param name="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">The search term.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
         /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
         /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
-        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
-        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
-        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
-        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
+        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param>
         /// <param name="enableUserData">Optional, include user data.</param>
         /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
         /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="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="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
         /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
         /// <response code="200">Persons returned.</response>
         /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns>
         [HttpGet]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetPersons(
-            [FromQuery] double? minCommunityRating,
-            [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
-            [FromQuery] string? parentId,
             [FromQuery] string? fields,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? includeItemTypes,
-            [FromQuery] ItemFilter[] filters,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
-            [FromQuery] string? genres,
-            [FromQuery] string? genreIds,
-            [FromQuery] string? officialRatings,
-            [FromQuery] string? tags,
-            [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+            [FromQuery] string? excludePersonTypes,
             [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [FromQuery] string? appearsInItemId,
             [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()
                 .AddItemFields(fields)
@@ -119,136 +89,28 @@ namespace Jellyfin.Api.Controllers
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
             User? user = null;
-            BaseItem parentItem;
 
             if (userId.HasValue && !userId.Equals(Guid.Empty))
             {
                 user = _userManager.GetUserById(userId.Value);
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
-            }
-            else
-            {
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
             }
 
-            var query = new InternalItemsQuery(user)
+            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),
-                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>
             {
-                Items = dtos.ToArray(),
-                TotalRecordCount = result.TotalRecordCount
+                Items = peopleItems.Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)).ToArray(),
+                TotalRecordCount = peopleItems.Count
             };
         }
 

+ 2 - 1
Jellyfin.Api/Controllers/PlaylistsController.cs

@@ -5,6 +5,7 @@ using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.PlaylistDtos;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Library;
@@ -152,7 +153,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
             var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
             if (playlist == null)

+ 1 - 1
Jellyfin.Api/Controllers/SessionController.cs

@@ -379,7 +379,7 @@ namespace Jellyfin.Api.Controllers
         public ActionResult PostCapabilities(
             [FromQuery] string? id,
             [FromQuery] string? playableMediaTypes,
-            [FromQuery] GeneralCommandType[] supportedCommands,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands,
             [FromQuery] bool supportsMediaControl = false,
             [FromQuery] bool supportsSync = false,
             [FromQuery] bool supportsPersistentIdentifier = true)

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

@@ -1,9 +1,9 @@
 using System;
 using System.ComponentModel.DataAnnotations;
-using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -46,7 +46,6 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Gets all studios from a given item, folder, or the entire library.
         /// </summary>
-        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
         /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
         /// <param name="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">Optional. Search term.</param>
@@ -54,22 +53,10 @@ namespace Jellyfin.Api.Controllers
         /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
         /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
         /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
         /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
-        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
-        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
-        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
-        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
         /// <param name="enableUserData">Optional, include user data.</param>
         /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
         /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
-        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
         /// <param name="userId">User id.</param>
         /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
         /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -81,7 +68,6 @@ namespace Jellyfin.Api.Controllers
         [HttpGet]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetStudios(
-            [FromQuery] double? minCommunityRating,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
@@ -89,22 +75,10 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
-            [FromQuery] string? genres,
-            [FromQuery] string? genreIds,
-            [FromQuery] string? officialRatings,
-            [FromQuery] string? tags,
-            [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] Guid? userId,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
@@ -117,44 +91,23 @@ namespace Jellyfin.Api.Controllers
                 .AddClientFields(Request)
                 .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 includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
-            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
 
             var query = new InternalItemsQuery(user)
             {
                 ExcludeItemTypes = excludeItemTypesArr,
                 IncludeItemTypes = includeItemTypesArr,
-                MediaTypes = mediaTypesArr,
                 StartIndex = startIndex,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, ',', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
-                Genres = RequestHelpers.Split(genres, ',', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds),
-                StudioIds = RequestHelpers.GetGuids(studioIds),
-                Person = person,
-                PersonIds = RequestHelpers.GetGuids(personIds),
-                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
-                Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
-                MinCommunityRating = minCommunityRating,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
                 EnableTotalRecordCount = enableTotalRecordCount
@@ -172,84 +125,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>

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

@@ -11,6 +11,9 @@ using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.SubtitleDtos;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
@@ -21,6 +24,7 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Providers;
+using MediaBrowser.Model.Subtitles;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
@@ -34,6 +38,7 @@ namespace Jellyfin.Api.Controllers
     [Route("")]
     public class SubtitleController : BaseJellyfinApiController
     {
+        private readonly IServerConfigurationManager _serverConfigurationManager;
         private readonly ILibraryManager _libraryManager;
         private readonly ISubtitleManager _subtitleManager;
         private readonly ISubtitleEncoder _subtitleEncoder;
@@ -46,6 +51,7 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Initializes a new instance of the <see cref="SubtitleController"/> class.
         /// </summary>
+        /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
         /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
         /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param>
         /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param>
@@ -55,6 +61,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
         /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
         public SubtitleController(
+            IServerConfigurationManager serverConfigurationManager,
             ILibraryManager libraryManager,
             ISubtitleManager subtitleManager,
             ISubtitleEncoder subtitleEncoder,
@@ -64,6 +71,7 @@ namespace Jellyfin.Api.Controllers
             IAuthorizationContext authContext,
             ILogger<SubtitleController> logger)
         {
+            _serverConfigurationManager = serverConfigurationManager;
             _libraryManager = libraryManager;
             _subtitleManager = subtitleManager;
             _subtitleEncoder = subtitleEncoder;
@@ -319,6 +327,33 @@ namespace Jellyfin.Api.Controllers
             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>
         /// Encodes a subtitle in the specified format.
         /// </summary>
@@ -351,5 +386,95 @@ namespace Jellyfin.Api.Controllers
                 copyTimestamps,
                 CancellationToken.None);
         }
+
+        /// <summary>
+        /// Gets a list of available fallback font files.
+        /// </summary>
+        /// <response code="200">Information retrieved.</response>
+        /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns>
+        [HttpGet("FallbackFont/Fonts")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public IEnumerable<FontFile> GetFallbackFontList()
+        {
+            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+            var fallbackFontPath = encodingOptions.FallbackFontPath;
+
+            if (!string.IsNullOrEmpty(fallbackFontPath))
+            {
+                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.ComponentModel.DataAnnotations;
 using System.Linq;
+using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Data.Enums;
@@ -9,6 +10,7 @@ using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
@@ -18,6 +20,7 @@ namespace Jellyfin.Api.Controllers
     /// The suggestions controller.
     /// </summary>
     [Route("")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
     public class SuggestionsController : BaseJellyfinApiController
     {
         private readonly IDtoService _dtoService;

+ 5 - 4
Jellyfin.Api/Controllers/TrailersController.cs

@@ -1,5 +1,6 @@
 using System;
 using Jellyfin.Api.Constants;
+using Jellyfin.Api.ModelBinders;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
@@ -125,7 +126,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isHd,
             [FromQuery] bool? is4K,
             [FromQuery] string? locationTypes,
-            [FromQuery] LocationType[] excludeLocationTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
             [FromQuery] bool? isMissing,
             [FromQuery] bool? isUnaired,
             [FromQuery] double? minCommunityRating,
@@ -147,10 +148,10 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? parentId,
             [FromQuery] string? fields,
             [FromQuery] string? excludeItemTypes,
-            [FromQuery] ItemFilter[] filters,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] string? mediaTypes,
-            [FromQuery] ImageType[] imageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
             [FromQuery] string? sortBy,
             [FromQuery] bool? isPlayed,
             [FromQuery] string? genres,
@@ -159,7 +160,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,

+ 5 - 4
Jellyfin.Api/Controllers/TvShowsController.cs

@@ -5,6 +5,7 @@ using System.Globalization;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Dto;
@@ -78,7 +79,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? parentId,
             [FromQuery] bool? enableImges,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool enableTotalRecordCount = true)
         {
@@ -135,7 +136,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? parentId,
             [FromQuery] bool? enableImges,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData)
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -207,7 +208,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] string? sortBy)
         {
@@ -326,7 +327,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? adjacentTo,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData)
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)

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

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

@@ -530,6 +530,33 @@ namespace Jellyfin.Api.Controllers
             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)
         {
             var users = _userManager.Users;

+ 2 - 1
Jellyfin.Api/Controllers/UserLibraryController.cs

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -272,7 +273,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isPlayed,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] int limit = 20,
             [FromQuery] bool groupItems = true)

+ 2 - 1
Jellyfin.Api/Controllers/YearsController.cs

@@ -5,6 +5,7 @@ using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -78,7 +79,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? sortBy,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] Guid? userId,
             [FromQuery] bool recursive = true,
             [FromQuery] bool? enableImages = true)

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

@@ -63,6 +63,19 @@ namespace Jellyfin.Api.Helpers
         public static string? GetToken(in ClaimsPrincipal user)
             => 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)
         {
             return user?.Identities

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

@@ -123,9 +123,8 @@ namespace Jellyfin.Api.Helpers
                     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
             {

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

@@ -166,7 +166,7 @@ namespace Jellyfin.Api.Helpers
             MediaSourceInfo mediaSource,
             DeviceProfile profile,
             AuthorizationInfo auth,
-            long? maxBitrate,
+            int? maxBitrate,
             long startTimeTicks,
             string mediaSourceId,
             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 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);
+            }
+        }
+    }
+}

+ 33 - 23
Jellyfin.Api/Helpers/RequestHelpers.cs

@@ -1,11 +1,13 @@
 using System;
-using System.Collections.Generic;
 using System.Linq;
-using System.Net;
+using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Http;
@@ -163,31 +165,39 @@ namespace Jellyfin.Api.Helpers
                 .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)
+        internal static QueryResult<BaseItemDto> CreateQueryResult(
+            QueryResult<(BaseItem, ItemCounts)> result,
+            DtoOptions dtoOptions,
+            IDtoService dtoService,
+            bool includeItemTypes,
+            User? user)
         {
-            if (string.IsNullOrEmpty(imageTypes))
+            var dtos = result.Items.Select(i =>
             {
-                return Array.Empty<ImageType>();
-            }
+                var (baseItem, counts) = i;
+                var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
 
-            return Split(imageTypes, ',', true)
-                .Select(v =>
+                if (includeItemTypes)
                 {
-                    if (Enum.TryParse(v, true, out ImageType value))
-                    {
-                        return (ImageType?)value;
-                    }
-
-                    return null;
-                })
-                .Where(i => i.HasValue)
-                .Select(i => i!.Value)
-                .ToArray();
+                    dto.ChildCount = counts.ItemCount;
+                    dto.ProgramCount = counts.ProgramCount;
+                    dto.SeriesCount = counts.SeriesCount;
+                    dto.EpisodeCount = counts.EpisodeCount;
+                    dto.MovieCount = counts.MovieCount;
+                    dto.TrailerCount = counts.TrailerCount;
+                    dto.AlbumCount = counts.AlbumCount;
+                    dto.SongCount = counts.SongCount;
+                    dto.ArtistCount = counts.ArtistCount;
+                }
+
+                return dto;
+            });
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos.ToArray(),
+                TotalRecordCount = result.TotalRecordCount
+            };
         }
     }
 }

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

@@ -50,9 +50,9 @@ namespace Jellyfin.Api.Helpers
 
             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);

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

@@ -15,7 +15,7 @@ namespace Jellyfin.Api.ModelBinders
         public Task BindModelAsync(ModelBindingContext bindingContext)
         {
             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);
 
             if (valueProviderResult.Length > 1)

+ 0 - 29
Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinderProvider.cs

@@ -1,29 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-using Microsoft.AspNetCore.Mvc.ModelBinding;
-
-namespace Jellyfin.Api.ModelBinders
-{
-    /// <summary>
-    /// Comma delimited array model binder provider.
-    /// </summary>
-    public class CommaDelimitedArrayModelBinderProvider : IModelBinderProvider
-    {
-        private readonly IModelBinder _binder;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CommaDelimitedArrayModelBinderProvider"/> class.
-        /// </summary>
-        public CommaDelimitedArrayModelBinderProvider()
-        {
-            _binder = new CommaDelimitedArrayModelBinder();
-        }
-
-        /// <inheritdoc />
-        public IModelBinder? GetBinder(ModelBinderProviderContext context)
-        {
-            return context.Metadata.ModelType.IsArray ? _binder : null;
-        }
-    }
-}

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

+ 12 - 0
Jellyfin.Server.Implementations/Activity/ActivityManager.cs

@@ -72,6 +72,18 @@ namespace Jellyfin.Server.Implementations.Activity
             };
         }
 
+        /// <inheritdoc />
+        public async Task CleanAsync(DateTime startDate)
+        {
+            await using var dbContext = _provider.CreateContext();
+            var entries = dbContext.ActivityLogs
+                .AsQueryable()
+                .Where(entry => entry.DateCreated <= startDate);
+
+            dbContext.RemoveRange(entries);
+            await dbContext.SaveChangesAsync().ConfigureAwait(false);
+        }
+
         private static ActivityLogEntry ConvertToOldModel(ActivityLog entry)
         {
             return new ActivityLogEntry

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

@@ -4,6 +4,7 @@ using System.IO;
 using System.Linq;
 using System.Net;
 using System.Reflection;
+using Emby.Server.Implementations;
 using Jellyfin.Api.Auth;
 using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
 using Jellyfin.Api.Auth.DownloadPolicy;
@@ -16,7 +17,6 @@ using Jellyfin.Api.Auth.LocalAccessPolicy;
 using Jellyfin.Api.Auth.RequiresElevationPolicy;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Controllers;
-using Jellyfin.Api.ModelBinders;
 using Jellyfin.Server.Configuration;
 using Jellyfin.Server.Filters;
 using Jellyfin.Server.Formatters;
@@ -28,6 +28,8 @@ using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Cors.Infrastructure;
 using Microsoft.AspNetCore.HttpOverrides;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.OpenApi.Any;
+using Microsoft.OpenApi.Interfaces;
 using Microsoft.OpenApi.Models;
 using Swashbuckle.AspNetCore.SwaggerGen;
 using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
@@ -167,8 +169,6 @@ namespace Jellyfin.Server.Extensions
 
                     opts.OutputFormatters.Add(new CssOutputFormatter());
                     opts.OutputFormatters.Add(new XmlOutputFormatter());
-
-                    opts.ModelBinderProviders.Insert(0, new CommaDelimitedArrayModelBinderProvider());
                 })
 
                 // Clear app parts to avoid other assemblies being picked up
@@ -212,7 +212,19 @@ namespace Jellyfin.Server.Extensions
         {
             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
                 {
                     Type = SecuritySchemeType.ApiKey,

+ 1 - 1
Jellyfin.Server/Jellyfin.Server.csproj

@@ -43,7 +43,7 @@
     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.9" />
     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="3.1.9" />
     <PackageReference Include="prometheus-net" Version="4.0.0" />
-    <PackageReference Include="prometheus-net.AspNetCore" Version="3.6.0" />
+    <PackageReference Include="prometheus-net.AspNetCore" Version="4.0.0" />
     <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
     <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
     <PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" />

部分文件因文件數量過多而無法顯示