Browse Source

Merge branch 'master' into PlugsVersionNumberFix

Claus Vium 4 years ago
parent
commit
dca3f62ff8
100 changed files with 2365 additions and 1705 deletions
  1. 2 2
      .ci/azure-pipelines-test.yml
  2. 36 0
      .github/workflows/codeql-analysis.yml
  3. 1 0
      CONTRIBUTORS.md
  4. 22 1
      Dockerfile
  5. 0 1
      Emby.Dlna/ContentDirectory/ControlHandler.cs
  6. 2 1
      Emby.Dlna/Eventing/DlnaEventManager.cs
  7. 25 15
      Emby.Dlna/Main/DlnaEntryPoint.cs
  8. 0 1
      Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs
  9. 2 4
      Emby.Dlna/PlayTo/Device.cs
  10. 18 29
      Emby.Dlna/PlayTo/PlayToController.cs
  11. 21 20
      Emby.Dlna/PlayTo/PlayToManager.cs
  12. 2 2
      Emby.Dlna/PlayTo/PlaylistItemFactory.cs
  13. 1 2
      Emby.Dlna/PlayTo/SsdpHttpClient.cs
  14. 8 8
      Emby.Dlna/PlayTo/TransportCommands.cs
  15. 2 0
      Emby.Dlna/Properties/AssemblyInfo.cs
  16. 1 17
      Emby.Dlna/Server/DescriptionXmlBuilder.cs
  17. 5 1
      Emby.Naming/Video/VideoListResolver.cs
  18. 100 197
      Emby.Server.Implementations/ApplicationHost.cs
  19. 5 5
      Emby.Server.Implementations/Channels/ChannelManager.cs
  20. 22 7
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  21. 9 62
      Emby.Server.Implementations/Devices/DeviceManager.cs
  22. 2 3
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  23. 7 4
      Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs
  24. 5 2
      Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
  25. 8 1
      Emby.Server.Implementations/HttpServer/Security/AuthService.cs
  26. 3 1
      Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
  27. 130 0
      Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs
  28. 45 16
      Emby.Server.Implementations/Library/LibraryManager.cs
  29. 2 2
      Emby.Server.Implementations/Library/SearchEngine.cs
  30. 33 56
      Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
  31. 1 2
      Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs
  32. 1 2
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
  33. 26 25
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
  34. 50 26
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs
  35. 1 1
      Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
  36. 1 1
      Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs
  37. 6 1
      Emby.Server.Implementations/Localization/Core/da.json
  38. 6 1
      Emby.Server.Implementations/Localization/Core/el.json
  39. 5 1
      Emby.Server.Implementations/Localization/Core/es-MX.json
  40. 1 1
      Emby.Server.Implementations/Localization/Core/es.json
  41. 3 1
      Emby.Server.Implementations/Localization/Core/fi.json
  42. 2 1
      Emby.Server.Implementations/Localization/Core/fr-CA.json
  43. 2 2
      Emby.Server.Implementations/Localization/Core/fr.json
  44. 4 1
      Emby.Server.Implementations/Localization/Core/hu.json
  45. 6 1
      Emby.Server.Implementations/Localization/Core/id.json
  46. 4 1
      Emby.Server.Implementations/Localization/Core/it.json
  47. 5 2
      Emby.Server.Implementations/Localization/Core/nl.json
  48. 6 1
      Emby.Server.Implementations/Localization/Core/pt-PT.json
  49. 6 1
      Emby.Server.Implementations/Localization/Core/pt.json
  50. 4 1
      Emby.Server.Implementations/Localization/Core/ro.json
  51. 4 1
      Emby.Server.Implementations/Localization/Core/ru.json
  52. 19 14
      Emby.Server.Implementations/Localization/Core/sk.json
  53. 2 2
      Emby.Server.Implementations/Localization/Core/ta.json
  54. 4 2
      Emby.Server.Implementations/Localization/Core/tr.json
  55. 7 2
      Emby.Server.Implementations/Localization/Core/uk.json
  56. 6 6
      Emby.Server.Implementations/Localization/Core/vi.json
  57. 4 1
      Emby.Server.Implementations/Localization/Core/zh-TW.json
  58. 6 0
      Emby.Server.Implementations/Localization/countries.json
  59. 10 10
      Emby.Server.Implementations/MediaEncoder/EncodingManager.cs
  60. 0 556
      Emby.Server.Implementations/Networking/NetworkManager.cs
  61. 3 3
      Emby.Server.Implementations/Playlists/PlaylistManager.cs
  62. 1 1
      Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs
  63. 0 45
      Emby.Server.Implementations/ResourceFileManager.cs
  64. 38 24
      Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs
  65. 3 21
      Emby.Server.Implementations/Session/SessionManager.cs
  66. 16 24
      Emby.Server.Implementations/TV/TVSeriesManager.cs
  67. 1 1
      Emby.Server.Implementations/Udp/UdpServer.cs
  68. 101 28
      Emby.Server.Implementations/Updates/InstallationManager.cs
  69. 4 1
      Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
  70. 1 1
      Jellyfin.Api/Controllers/ApiKeyController.cs
  71. 63 82
      Jellyfin.Api/Controllers/ArtistsController.cs
  72. 169 6
      Jellyfin.Api/Controllers/AudioController.cs
  73. 1 1
      Jellyfin.Api/Controllers/BrandingController.cs
  74. 3 7
      Jellyfin.Api/Controllers/ChannelsController.cs
  75. 12 7
      Jellyfin.Api/Controllers/CollectionController.cs
  76. 1 1
      Jellyfin.Api/Controllers/DashboardController.cs
  77. 1 1
      Jellyfin.Api/Controllers/DlnaServerController.cs
  78. 20 20
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  79. 38 38
      Jellyfin.Api/Controllers/FilterController.cs
  80. 10 10
      Jellyfin.Api/Controllers/GenresController.cs
  81. 21 7
      Jellyfin.Api/Controllers/HlsSegmentController.cs
  82. 686 56
      Jellyfin.Api/Controllers/ImageController.cs
  83. 3 3
      Jellyfin.Api/Controllers/InstantMixController.cs
  84. 1 1
      Jellyfin.Api/Controllers/ItemLookupController.cs
  85. 1 1
      Jellyfin.Api/Controllers/ItemUpdateController.cs
  86. 323 87
      Jellyfin.Api/Controllers/ItemsController.cs
  87. 6 7
      Jellyfin.Api/Controllers/LibraryController.cs
  88. 17 18
      Jellyfin.Api/Controllers/LiveTvController.cs
  89. 1 1
      Jellyfin.Api/Controllers/LocalizationController.cs
  90. 39 21
      Jellyfin.Api/Controllers/MediaInfoController.cs
  91. 3 3
      Jellyfin.Api/Controllers/MoviesController.cs
  92. 10 10
      Jellyfin.Api/Controllers/MusicGenresController.cs
  93. 5 5
      Jellyfin.Api/Controllers/PackageController.cs
  94. 7 7
      Jellyfin.Api/Controllers/PersonsController.cs
  95. 8 7
      Jellyfin.Api/Controllers/PlaylistsController.cs
  96. 3 2
      Jellyfin.Api/Controllers/PlaystateController.cs
  97. 1 1
      Jellyfin.Api/Controllers/PluginsController.cs
  98. 8 7
      Jellyfin.Api/Controllers/SearchController.cs
  99. 7 6
      Jellyfin.Api/Controllers/SessionController.cs
  100. 8 6
      Jellyfin.Api/Controllers/StartupController.cs

+ 2 - 2
.ci/azure-pipelines-test.yml

@@ -30,11 +30,11 @@ jobs:
 
 
       # This is required for the SonarCloud analyzer
       # This is required for the SonarCloud analyzer
       - task: UseDotNet@2
       - task: UseDotNet@2
-        displayName: "Install .NET Core SDK 2.1"
+        displayName: "Install .NET SDK 5.x"
         condition: eq(variables['ImageName'], 'ubuntu-latest')
         condition: eq(variables['ImageName'], 'ubuntu-latest')
         inputs:
         inputs:
           packageType: sdk
           packageType: sdk
-          version: '2.1.805'
+          version: '5.x'
 
 
       - task: UseDotNet@2
       - task: UseDotNet@2
         displayName: "Update DotNet"
         displayName: "Update DotNet"

+ 36 - 0
.github/workflows/codeql-analysis.yml

@@ -0,0 +1,36 @@
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+  schedule:
+    - cron: '24 2 * * 4'
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'csharp' ]
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v2
+    - name: Setup .NET Core
+      uses: actions/setup-dotnet@v1
+      with:
+        dotnet-version: '5.0.100'
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v1
+      with:
+        languages: ${{ matrix.language }}
+        queries: +security-extended
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v1
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v1

+ 1 - 0
CONTRIBUTORS.md

@@ -7,6 +7,7 @@
  - [anthonylavado](https://github.com/anthonylavado)
  - [anthonylavado](https://github.com/anthonylavado)
  - [Artiume](https://github.com/Artiume)
  - [Artiume](https://github.com/Artiume)
  - [AThomsen](https://github.com/AThomsen)
  - [AThomsen](https://github.com/AThomsen)
+ - [barongreenback](https://github.com/BaronGreenback)
  - [barronpm](https://github.com/barronpm)
  - [barronpm](https://github.com/barronpm)
  - [bilde2910](https://github.com/bilde2910)
  - [bilde2910](https://github.com/bilde2910)
  - [bfayers](https://github.com/bfayers)
  - [bfayers](https://github.com/bfayers)

+ 22 - 1
Dockerfile

@@ -27,8 +27,15 @@ ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
 
 
 COPY --from=builder /jellyfin /jellyfin
 COPY --from=builder /jellyfin /jellyfin
 COPY --from=web-builder /dist /jellyfin/jellyfin-web
 COPY --from=web-builder /dist /jellyfin/jellyfin-web
+
+# https://github.com/intel/compute-runtime/releases
+ARG GMMLIB_VERSION=20.3.2
+ARG IGC_VERSION=1.0.5435
+ARG NEO_VERSION=20.46.18421
+ARG LEVEL_ZERO_VERSION=1.0.18421
+
 # Install dependencies:
 # Install dependencies:
-#   mesa-va-drivers: needed for AMD VAAPI
+# mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding.
 RUN apt-get update \
 RUN apt-get update \
  && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https \
  && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https \
  && wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \
  && wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \
@@ -39,6 +46,20 @@ RUN apt-get update \
    jellyfin-ffmpeg \
    jellyfin-ffmpeg \
    openssl \
    openssl \
    locales \
    locales \
+# Intel VAAPI Tone mapping dependencies:
+# Prefer NEO to Beignet since the latter one doesn't support Comet Lake or newer for now.
+# Do not use the intel-opencl-icd package from repo since they will not build with RELEASE_WITH_REGKEYS enabled.
+ && mkdir intel-compute-runtime \
+ && cd intel-compute-runtime \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-gmmlib_${GMMLIB_VERSION}_amd64.deb \
+ && wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-core_${IGC_VERSION}_amd64.deb \
+ && wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-opencl_${IGC_VERSION}_amd64.deb \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-opencl_${NEO_VERSION}_amd64.deb \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-ocloc_${NEO_VERSION}_amd64.deb \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-level-zero-gpu_${LEVEL_ZERO_VERSION}_amd64.deb \
+ && dpkg -i *.deb \
+ && cd .. \
+ && rm -rf intel-compute-runtime \
  && apt-get remove gnupg wget apt-transport-https -y \
  && apt-get remove gnupg wget apt-transport-https -y \
  && apt-get clean autoclean -y \
  && apt-get clean autoclean -y \
  && apt-get autoremove -y \
  && apt-get autoremove -y \

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

@@ -1681,7 +1681,6 @@ namespace Emby.Dlna.ContentDirectory
         private ServerItem GetItemFromObjectId(string id)
         private ServerItem GetItemFromObjectId(string id)
         {
         {
             return DidlBuilder.IsIdRoot(id)
             return DidlBuilder.IsIdRoot(id)
-
                  ? new ServerItem(_libraryManager.GetUserRootFolder())
                  ? new ServerItem(_libraryManager.GetUserRootFolder())
                  : ParseItemId(id);
                  : ParseItemId(id);
         }
         }

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

@@ -72,7 +72,8 @@ namespace Emby.Dlna.Eventing
                 Id = id,
                 Id = id,
                 CallbackUrl = callbackUrl,
                 CallbackUrl = callbackUrl,
                 SubscriptionTime = DateTime.UtcNow,
                 SubscriptionTime = DateTime.UtcNow,
-                TimeoutSeconds = timeout
+                TimeoutSeconds = timeout,
+                NotificationType = notificationType
             });
             });
 
 
             return GetEventSubscriptionResponse(id, requestedTimeoutString, timeout);
             return GetEventSubscriptionResponse(id, requestedTimeoutString, timeout);

+ 25 - 15
Emby.Dlna/Main/DlnaEntryPoint.cs

@@ -2,12 +2,14 @@
 
 
 using System;
 using System;
 using System.Globalization;
 using System.Globalization;
+using System.Linq;
 using System.Net.Http;
 using System.Net.Http;
 using System.Net.Sockets;
 using System.Net.Sockets;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Emby.Dlna.PlayTo;
 using Emby.Dlna.PlayTo;
 using Emby.Dlna.Ssdp;
 using Emby.Dlna.Ssdp;
+using Jellyfin.Networking.Manager;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
@@ -134,20 +136,20 @@ namespace Emby.Dlna.Main
         {
         {
             await ((DlnaManager)_dlnaManager).InitProfilesAsync().ConfigureAwait(false);
             await ((DlnaManager)_dlnaManager).InitProfilesAsync().ConfigureAwait(false);
 
 
-            await ReloadComponents().ConfigureAwait(false);
+            ReloadComponents();
 
 
             _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
             _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
         }
         }
 
 
-        private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
+        private void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
         {
         {
             if (string.Equals(e.Key, "dlna", StringComparison.OrdinalIgnoreCase))
             if (string.Equals(e.Key, "dlna", StringComparison.OrdinalIgnoreCase))
             {
             {
-                await ReloadComponents().ConfigureAwait(false);
+                ReloadComponents();
             }
             }
         }
         }
 
 
-        private async Task ReloadComponents()
+        private void ReloadComponents()
         {
         {
             var options = _config.GetDlnaConfiguration();
             var options = _config.GetDlnaConfiguration();
 
 
@@ -155,7 +157,7 @@ namespace Emby.Dlna.Main
 
 
             if (options.EnableServer)
             if (options.EnableServer)
             {
             {
-                await StartDevicePublisher(options).ConfigureAwait(false);
+                StartDevicePublisher(options);
             }
             }
             else
             else
             {
             {
@@ -225,7 +227,7 @@ namespace Emby.Dlna.Main
             }
             }
         }
         }
 
 
-        public async Task StartDevicePublisher(Configuration.DlnaOptions options)
+        public void StartDevicePublisher(Configuration.DlnaOptions options)
         {
         {
             if (!options.BlastAliveMessages)
             if (!options.BlastAliveMessages)
             {
             {
@@ -245,7 +247,7 @@ namespace Emby.Dlna.Main
                     SupportPnpRootDevice = false
                     SupportPnpRootDevice = false
                 };
                 };
 
 
-                await RegisterServerEndpoints().ConfigureAwait(false);
+                RegisterServerEndpoints();
 
 
                 _publisher.StartBroadcastingAliveMessages(TimeSpan.FromSeconds(options.BlastAliveMessageIntervalSeconds));
                 _publisher.StartBroadcastingAliveMessages(TimeSpan.FromSeconds(options.BlastAliveMessageIntervalSeconds));
             }
             }
@@ -255,14 +257,22 @@ namespace Emby.Dlna.Main
             }
             }
         }
         }
 
 
-        private async Task RegisterServerEndpoints()
+        private void RegisterServerEndpoints()
         {
         {
-            var addresses = await _appHost.GetLocalIpAddresses().ConfigureAwait(false);
-
             var udn = CreateUuid(_appHost.SystemId);
             var udn = CreateUuid(_appHost.SystemId);
             var descriptorUri = "/dlna/" + udn + "/description.xml";
             var descriptorUri = "/dlna/" + udn + "/description.xml";
 
 
-            foreach (var address in addresses)
+            var bindAddresses = NetworkManager.CreateCollection(
+                _networkManager.GetInternalBindAddresses()
+                .Where(i => i.AddressFamily == AddressFamily.InterNetwork || (i.AddressFamily == AddressFamily.InterNetworkV6 && i.Address.ScopeId != 0)));
+
+            if (bindAddresses.Count == 0)
+            {
+                // No interfaces returned, so use loopback.
+                bindAddresses = _networkManager.GetLoopbacks();
+            }
+
+            foreach (IPNetAddress address in bindAddresses)
             {
             {
                 if (address.AddressFamily == AddressFamily.InterNetworkV6)
                 if (address.AddressFamily == AddressFamily.InterNetworkV6)
                 {
                 {
@@ -271,7 +281,7 @@ namespace Emby.Dlna.Main
                 }
                 }
 
 
                 // Limit to LAN addresses only
                 // Limit to LAN addresses only
-                if (!_networkManager.IsAddressInSubnets(address, true, true))
+                if (!_networkManager.IsInLocalNetwork(address))
                 {
                 {
                     continue;
                     continue;
                 }
                 }
@@ -280,14 +290,14 @@ namespace Emby.Dlna.Main
 
 
                 _logger.LogInformation("Registering publisher for {0} on {1}", fullService, address);
                 _logger.LogInformation("Registering publisher for {0} on {1}", fullService, address);
 
 
-                var uri = new Uri(_appHost.GetLocalApiUrl(address) + descriptorUri);
+                var uri = new Uri(_appHost.GetSmartApiUrl(address.Address) + descriptorUri);
 
 
                 var device = new SsdpRootDevice
                 var device = new SsdpRootDevice
                 {
                 {
                     CacheLifetime = TimeSpan.FromSeconds(1800), // How long SSDP clients can cache this info.
                     CacheLifetime = TimeSpan.FromSeconds(1800), // How long SSDP clients can cache this info.
                     Location = uri, // Must point to the URL that serves your devices UPnP description document.
                     Location = uri, // Must point to the URL that serves your devices UPnP description document.
-                    Address = address,
-                    SubnetMask = _networkManager.GetLocalIpSubnetMask(address),
+                    Address = address.Address,
+                    PrefixLength = address.PrefixLength,
                     FriendlyName = "Jellyfin",
                     FriendlyName = "Jellyfin",
                     Manufacturer = "Jellyfin",
                     Manufacturer = "Jellyfin",
                     ModelName = "Jellyfin Server",
                     ModelName = "Jellyfin Server",

+ 0 - 1
Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs

@@ -1,6 +1,5 @@
 using System.Collections.Generic;
 using System.Collections.Generic;
 using Emby.Dlna.Common;
 using Emby.Dlna.Common;
-using MediaBrowser.Model.Dlna;
 
 
 namespace Emby.Dlna.MediaReceiverRegistrar
 namespace Emby.Dlna.MediaReceiverRegistrar
 {
 {

+ 2 - 4
Emby.Dlna/PlayTo/Device.cs

@@ -12,8 +12,6 @@ using System.Xml;
 using System.Xml.Linq;
 using System.Xml.Linq;
 using Emby.Dlna.Common;
 using Emby.Dlna.Common;
 using Emby.Dlna.Ssdp;
 using Emby.Dlna.Ssdp;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Configuration;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 
 
 namespace Emby.Dlna.PlayTo
 namespace Emby.Dlna.PlayTo
@@ -345,7 +343,7 @@ namespace Emby.Dlna.PlayTo
             RestartTimer(true);
             RestartTimer(true);
         }
         }
 
 
-        private string CreateDidlMeta(string value)
+        private static string CreateDidlMeta(string value)
         {
         {
             if (string.IsNullOrEmpty(value))
             if (string.IsNullOrEmpty(value))
             {
             {
@@ -962,7 +960,7 @@ namespace Emby.Dlna.PlayTo
                 url = "/dmr/" + url;
                 url = "/dmr/" + url;
             }
             }
 
 
-            if (!url.StartsWith("/", StringComparison.Ordinal))
+            if (!url.StartsWith('/'))
             {
             {
                 url = "/" + url;
                 url = "/" + url;
             }
             }

+ 18 - 29
Emby.Dlna/PlayTo/PlayToController.cs

@@ -9,7 +9,6 @@ using System.Threading.Tasks;
 using Emby.Dlna.Didl;
 using Emby.Dlna.Didl;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Events;
 using Jellyfin.Data.Events;
-using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
@@ -41,7 +40,6 @@ namespace Emby.Dlna.PlayTo
         private readonly IUserDataManager _userDataManager;
         private readonly IUserDataManager _userDataManager;
         private readonly ILocalizationManager _localization;
         private readonly ILocalizationManager _localization;
         private readonly IMediaSourceManager _mediaSourceManager;
         private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IConfigurationManager _config;
         private readonly IMediaEncoder _mediaEncoder;
         private readonly IMediaEncoder _mediaEncoder;
 
 
         private readonly IDeviceDiscovery _deviceDiscovery;
         private readonly IDeviceDiscovery _deviceDiscovery;
@@ -68,7 +66,6 @@ namespace Emby.Dlna.PlayTo
             IUserDataManager userDataManager,
             IUserDataManager userDataManager,
             ILocalizationManager localization,
             ILocalizationManager localization,
             IMediaSourceManager mediaSourceManager,
             IMediaSourceManager mediaSourceManager,
-            IConfigurationManager config,
             IMediaEncoder mediaEncoder)
             IMediaEncoder mediaEncoder)
         {
         {
             _session = session;
             _session = session;
@@ -84,7 +81,6 @@ namespace Emby.Dlna.PlayTo
             _userDataManager = userDataManager;
             _userDataManager = userDataManager;
             _localization = localization;
             _localization = localization;
             _mediaSourceManager = mediaSourceManager;
             _mediaSourceManager = mediaSourceManager;
-            _config = config;
             _mediaEncoder = mediaEncoder;
             _mediaEncoder = mediaEncoder;
         }
         }
 
 
@@ -337,25 +333,17 @@ namespace Emby.Dlna.PlayTo
             }
             }
 
 
             var startIndex = command.StartIndex ?? 0;
             var startIndex = command.StartIndex ?? 0;
+            int len = items.Count - startIndex;
             if (startIndex > 0)
             if (startIndex > 0)
             {
             {
-                items = items.GetRange(startIndex, items.Count - startIndex);
+                items = items.GetRange(startIndex, len);
             }
             }
 
 
-            var playlist = new List<PlaylistItem>();
-            var isFirst = true;
-
-            foreach (var item in items)
+            var playlist = new PlaylistItem[len];
+            playlist[0] = CreatePlaylistItem(items[0], user, command.StartPositionTicks.Value, command.MediaSourceId, command.AudioStreamIndex, command.SubtitleStreamIndex);
+            for (int i = 1; i < len; i++)
             {
             {
-                if (isFirst && command.StartPositionTicks.HasValue)
-                {
-                    playlist.Add(CreatePlaylistItem(item, user, command.StartPositionTicks.Value, command.MediaSourceId, command.AudioStreamIndex, command.SubtitleStreamIndex));
-                    isFirst = false;
-                }
-                else
-                {
-                    playlist.Add(CreatePlaylistItem(item, user, 0, null, null, null));
-                }
+                playlist[i] = CreatePlaylistItem(items[i], user, 0, null, null, null);
             }
             }
 
 
             _logger.LogDebug("{0} - Playlist created", _session.DeviceName);
             _logger.LogDebug("{0} - Playlist created", _session.DeviceName);
@@ -468,8 +456,8 @@ namespace Emby.Dlna.PlayTo
                 _dlnaManager.GetDefaultProfile();
                 _dlnaManager.GetDefaultProfile();
 
 
             var mediaSources = item is IHasMediaSources
             var mediaSources = item is IHasMediaSources
-                ? _mediaSourceManager.GetStaticMediaSources(item, true, user)
-                : new List<MediaSourceInfo>();
+                ? _mediaSourceManager.GetStaticMediaSources(item, true, user).ToArray()
+                : Array.Empty<MediaSourceInfo>();
 
 
             var playlistItem = GetPlaylistItem(item, mediaSources, profile, _session.DeviceId, mediaSourceId, audioStreamIndex, subtitleStreamIndex);
             var playlistItem = GetPlaylistItem(item, mediaSources, profile, _session.DeviceId, mediaSourceId, audioStreamIndex, subtitleStreamIndex);
             playlistItem.StreamInfo.StartPositionTicks = startPostionTicks;
             playlistItem.StreamInfo.StartPositionTicks = startPostionTicks;
@@ -548,7 +536,7 @@ namespace Emby.Dlna.PlayTo
             return null;
             return null;
         }
         }
 
 
-        private PlaylistItem GetPlaylistItem(BaseItem item, List<MediaSourceInfo> mediaSources, DeviceProfile profile, string deviceId, string mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex)
+        private PlaylistItem GetPlaylistItem(BaseItem item, MediaSourceInfo[] mediaSources, DeviceProfile profile, string deviceId, string mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex)
         {
         {
             if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
             if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
             {
             {
@@ -557,7 +545,7 @@ namespace Emby.Dlna.PlayTo
                     StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildVideoItem(new VideoOptions
                     StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildVideoItem(new VideoOptions
                     {
                     {
                         ItemId = item.Id,
                         ItemId = item.Id,
-                        MediaSources = mediaSources.ToArray(),
+                        MediaSources = mediaSources,
                         Profile = profile,
                         Profile = profile,
                         DeviceId = deviceId,
                         DeviceId = deviceId,
                         MaxBitrate = profile.MaxStreamingBitrate,
                         MaxBitrate = profile.MaxStreamingBitrate,
@@ -577,7 +565,7 @@ namespace Emby.Dlna.PlayTo
                     StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildAudioItem(new AudioOptions
                     StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildAudioItem(new AudioOptions
                     {
                     {
                         ItemId = item.Id,
                         ItemId = item.Id,
-                        MediaSources = mediaSources.ToArray(),
+                        MediaSources = mediaSources,
                         Profile = profile,
                         Profile = profile,
                         DeviceId = deviceId,
                         DeviceId = deviceId,
                         MaxBitrate = profile.MaxStreamingBitrate,
                         MaxBitrate = profile.MaxStreamingBitrate,
@@ -590,7 +578,7 @@ namespace Emby.Dlna.PlayTo
 
 
             if (string.Equals(item.MediaType, MediaType.Photo, StringComparison.OrdinalIgnoreCase))
             if (string.Equals(item.MediaType, MediaType.Photo, StringComparison.OrdinalIgnoreCase))
             {
             {
-                return new PlaylistItemFactory().Create((Photo)item, profile);
+                return PlaylistItemFactory.Create((Photo)item, profile);
             }
             }
 
 
             throw new ArgumentException("Unrecognized item type.");
             throw new ArgumentException("Unrecognized item type.");
@@ -774,13 +762,14 @@ namespace Emby.Dlna.PlayTo
 
 
         private async Task SeekAfterTransportChange(long positionTicks, CancellationToken cancellationToken)
         private async Task SeekAfterTransportChange(long positionTicks, CancellationToken cancellationToken)
         {
         {
-            const int maxWait = 15000000;
-            const int interval = 500;
+            const int MaxWait = 15000000;
+            const int Interval = 500;
+
             var currentWait = 0;
             var currentWait = 0;
-            while (_device.TransportState != TransportState.Playing && currentWait < maxWait)
+            while (_device.TransportState != TransportState.Playing && currentWait < MaxWait)
             {
             {
-                await Task.Delay(interval).ConfigureAwait(false);
-                currentWait += interval;
+                await Task.Delay(Interval).ConfigureAwait(false);
+                currentWait += Interval;
             }
             }
 
 
             await _device.Seek(TimeSpan.FromTicks(positionTicks), cancellationToken).ConfigureAwait(false);
             await _device.Seek(TimeSpan.FromTicks(positionTicks), cancellationToken).ConfigureAwait(false);

+ 21 - 20
Emby.Dlna/PlayTo/PlayToManager.cs

@@ -3,13 +3,11 @@
 using System;
 using System;
 using System.Globalization;
 using System.Globalization;
 using System.Linq;
 using System.Linq;
-using System.Net;
 using System.Net.Http;
 using System.Net.Http;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Data.Events;
 using Jellyfin.Data.Events;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Net;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Dlna;
@@ -92,10 +90,10 @@ namespace Emby.Dlna.PlayTo
             string location = info.Location.ToString();
             string location = info.Location.ToString();
 
 
             // It has to report that it's a media renderer
             // It has to report that it's a media renderer
-            if (usn.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) == -1 &&
-                nt.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) == -1)
+            if (!usn.Contains("MediaRenderer:", StringComparison.OrdinalIgnoreCase)
+                && !nt.Contains("MediaRenderer:", StringComparison.OrdinalIgnoreCase))
             {
             {
-                // _logger.LogDebug("Upnp device {0} does not contain a MediaRenderer device (0).", location);
+                _logger.LogDebug("Upnp device {0} does not contain a MediaRenderer device (0).", location);
                 return;
                 return;
             }
             }
 
 
@@ -130,24 +128,36 @@ namespace Emby.Dlna.PlayTo
             }
             }
         }
         }
 
 
-        private static string GetUuid(string usn)
+        internal static string GetUuid(string usn)
         {
         {
             const string UuidStr = "uuid:";
             const string UuidStr = "uuid:";
             const string UuidColonStr = "::";
             const string UuidColonStr = "::";
 
 
             var index = usn.IndexOf(UuidStr, StringComparison.OrdinalIgnoreCase);
             var index = usn.IndexOf(UuidStr, StringComparison.OrdinalIgnoreCase);
+            if (index == -1)
+            {
+                return usn.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+            }
+
+            ReadOnlySpan<char> tmp = usn.AsSpan()[(index + UuidStr.Length)..];
+
+            index = tmp.IndexOf(UuidColonStr, StringComparison.OrdinalIgnoreCase);
             if (index != -1)
             if (index != -1)
             {
             {
-                return usn.Substring(index + UuidStr.Length);
+                tmp = tmp[..index];
             }
             }
 
 
-            index = usn.IndexOf(UuidColonStr, StringComparison.OrdinalIgnoreCase);
+            index = tmp.IndexOf('{');
             if (index != -1)
             if (index != -1)
             {
             {
-                usn = usn.Substring(0, index + UuidColonStr.Length);
+                int endIndex = tmp.IndexOf('}');
+                if (endIndex != -1)
+                {
+                    tmp = tmp[(index + 1)..endIndex];
+                }
             }
             }
 
 
-            return usn.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+            return tmp.ToString();
         }
         }
 
 
         private async Task AddDevice(UpnpDeviceInfo info, string location, CancellationToken cancellationToken)
         private async Task AddDevice(UpnpDeviceInfo info, string location, CancellationToken cancellationToken)
@@ -177,15 +187,7 @@ namespace Emby.Dlna.PlayTo
 
 
                 _sessionManager.UpdateDeviceName(sessionInfo.Id, deviceName);
                 _sessionManager.UpdateDeviceName(sessionInfo.Id, deviceName);
 
 
-                string serverAddress;
-                if (info.LocalIpAddress == null || info.LocalIpAddress.Equals(IPAddress.Any) || info.LocalIpAddress.Equals(IPAddress.IPv6Any))
-                {
-                    serverAddress = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
-                }
-                else
-                {
-                    serverAddress = _appHost.GetLocalApiUrl(info.LocalIpAddress);
-                }
+                string serverAddress = _appHost.GetSmartApiUrl(info.LocalIpAddress);
 
 
                 controller = new PlayToController(
                 controller = new PlayToController(
                     sessionInfo,
                     sessionInfo,
@@ -201,7 +203,6 @@ namespace Emby.Dlna.PlayTo
                     _userDataManager,
                     _userDataManager,
                     _localization,
                     _localization,
                     _mediaSourceManager,
                     _mediaSourceManager,
-                    _config,
                     _mediaEncoder);
                     _mediaEncoder);
 
 
                 sessionInfo.AddController(controller);
                 sessionInfo.AddController(controller);

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

@@ -8,9 +8,9 @@ using MediaBrowser.Model.Session;
 
 
 namespace Emby.Dlna.PlayTo
 namespace Emby.Dlna.PlayTo
 {
 {
-    public class PlaylistItemFactory
+    public static class PlaylistItemFactory
     {
     {
-        public PlaylistItem Create(Photo item, DeviceProfile profile)
+        public static PlaylistItem Create(Photo item, DeviceProfile profile)
         {
         {
             var playlistItem = new PlaylistItem
             var playlistItem = new PlaylistItem
             {
             {

+ 1 - 2
Emby.Dlna/PlayTo/SsdpHttpClient.cs

@@ -4,7 +4,6 @@ using System;
 using System.Globalization;
 using System.Globalization;
 using System.IO;
 using System.IO;
 using System.Net.Http;
 using System.Net.Http;
-using System.Net.Http.Headers;
 using System.Net.Mime;
 using System.Net.Mime;
 using System.Text;
 using System.Text;
 using System.Threading;
 using System.Threading;
@@ -60,7 +59,7 @@ namespace Emby.Dlna.PlayTo
                 return serviceUrl;
                 return serviceUrl;
             }
             }
 
 
-            if (!serviceUrl.StartsWith("/", StringComparison.Ordinal))
+            if (!serviceUrl.StartsWith('/'))
             {
             {
                 serviceUrl = "/" + serviceUrl;
                 serviceUrl = "/" + serviceUrl;
             }
             }

+ 8 - 8
Emby.Dlna/PlayTo/TransportCommands.cs

@@ -78,7 +78,7 @@ namespace Emby.Dlna.PlayTo
 
 
         private static StateVariable FromXml(XElement container)
         private static StateVariable FromXml(XElement container)
         {
         {
-            var allowedValues = new List<string>();
+            var allowedValues = Array.Empty<string>();
             var element = container.Descendants(UPnpNamespaces.Svc + "allowedValueList")
             var element = container.Descendants(UPnpNamespaces.Svc + "allowedValueList")
                 .FirstOrDefault();
                 .FirstOrDefault();
 
 
@@ -86,14 +86,14 @@ namespace Emby.Dlna.PlayTo
             {
             {
                 var values = element.Descendants(UPnpNamespaces.Svc + "allowedValue");
                 var values = element.Descendants(UPnpNamespaces.Svc + "allowedValue");
 
 
-                allowedValues.AddRange(values.Select(child => child.Value));
+                allowedValues = values.Select(child => child.Value).ToArray();
             }
             }
 
 
             return new StateVariable
             return new StateVariable
             {
             {
                 Name = container.GetValue(UPnpNamespaces.Svc + "name"),
                 Name = container.GetValue(UPnpNamespaces.Svc + "name"),
                 DataType = container.GetValue(UPnpNamespaces.Svc + "dataType"),
                 DataType = container.GetValue(UPnpNamespaces.Svc + "dataType"),
-                AllowedValues = allowedValues.ToArray()
+                AllowedValues = allowedValues
             };
             };
         }
         }
 
 
@@ -103,12 +103,12 @@ namespace Emby.Dlna.PlayTo
 
 
             foreach (var arg in action.ArgumentList)
             foreach (var arg in action.ArgumentList)
             {
             {
-                if (arg.Direction == "out")
+                if (string.Equals(arg.Direction, "out", StringComparison.Ordinal))
                 {
                 {
                     continue;
                     continue;
                 }
                 }
 
 
-                if (arg.Name == "InstanceID")
+                if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal))
                 {
                 {
                     stateString += BuildArgumentXml(arg, "0");
                     stateString += BuildArgumentXml(arg, "0");
                 }
                 }
@@ -127,12 +127,12 @@ namespace Emby.Dlna.PlayTo
 
 
             foreach (var arg in action.ArgumentList)
             foreach (var arg in action.ArgumentList)
             {
             {
-                if (arg.Direction == "out")
+                if (string.Equals(arg.Direction, "out", StringComparison.Ordinal))
                 {
                 {
                     continue;
                     continue;
                 }
                 }
 
 
-                if (arg.Name == "InstanceID")
+                if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal))
                 {
                 {
                     stateString += BuildArgumentXml(arg, "0");
                     stateString += BuildArgumentXml(arg, "0");
                 }
                 }
@@ -151,7 +151,7 @@ namespace Emby.Dlna.PlayTo
 
 
             foreach (var arg in action.ArgumentList)
             foreach (var arg in action.ArgumentList)
             {
             {
-                if (arg.Name == "InstanceID")
+                if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal))
                 {
                 {
                     stateString += BuildArgumentXml(arg, "0");
                     stateString += BuildArgumentXml(arg, "0");
                 }
                 }

+ 2 - 0
Emby.Dlna/Properties/AssemblyInfo.cs

@@ -1,5 +1,6 @@
 using System.Reflection;
 using System.Reflection;
 using System.Resources;
 using System.Resources;
+using System.Runtime.CompilerServices;
 
 
 // General Information about an assembly is controlled through the following
 // General Information about an assembly is controlled through the following
 // set of attributes. Change these attribute values to modify the information
 // set of attributes. Change these attribute values to modify the information
@@ -13,6 +14,7 @@ using System.Resources;
 [assembly: AssemblyTrademark("")]
 [assembly: AssemblyTrademark("")]
 [assembly: AssemblyCulture("")]
 [assembly: AssemblyCulture("")]
 [assembly: NeutralResourcesLanguage("en")]
 [assembly: NeutralResourcesLanguage("en")]
+[assembly: InternalsVisibleTo("Jellyfin.Dlna.Tests")]
 
 
 // Version information for an assembly consists of the following four values:
 // Version information for an assembly consists of the following four values:
 //
 //

+ 1 - 17
Emby.Dlna/Server/DescriptionXmlBuilder.cs

@@ -40,8 +40,6 @@ namespace Emby.Dlna.Server
             _serverId = serverId;
             _serverId = serverId;
         }
         }
 
 
-        private static bool EnableAbsoluteUrls => false;
-
         public string GetXml()
         public string GetXml()
         {
         {
             var builder = new StringBuilder();
             var builder = new StringBuilder();
@@ -75,13 +73,6 @@ namespace Emby.Dlna.Server
             builder.Append("<minor>0</minor>");
             builder.Append("<minor>0</minor>");
             builder.Append("</specVersion>");
             builder.Append("</specVersion>");
 
 
-            if (!EnableAbsoluteUrls)
-            {
-                builder.Append("<URLBase>")
-                    .Append(SecurityElement.Escape(_serverAddress))
-                    .Append("</URLBase>");
-            }
-
             AppendDeviceInfo(builder);
             AppendDeviceInfo(builder);
 
 
             builder.Append("</root>");
             builder.Append("</root>");
@@ -257,14 +248,7 @@ namespace Emby.Dlna.Server
                 return string.Empty;
                 return string.Empty;
             }
             }
 
 
-            url = url.TrimStart('/');
-
-            url = "/dlna/" + _serverUdn + "/" + url;
-
-            if (EnableAbsoluteUrls)
-            {
-                url = _serverAddress.TrimEnd('/') + url;
-            }
+            url = _serverAddress.TrimEnd('/') + "/dlna/" + _serverUdn + "/" + url.TrimStart('/');
 
 
             return SecurityElement.Escape(url);
             return SecurityElement.Escape(url);
         }
         }

+ 5 - 1
Emby.Naming/Video/VideoListResolver.cs

@@ -227,7 +227,11 @@ namespace Emby.Naming.Video
                     testFilename = cleanName.ToString();
                     testFilename = cleanName.ToString();
                 }
                 }
 
 
-                testFilename = testFilename.Substring(folderName.Length).Trim();
+                if (folderName.Length <= testFilename.Length)
+                {
+                    testFilename = testFilename.Substring(folderName.Length).Trim();
+                }
+
                 return string.IsNullOrEmpty(testFilename)
                 return string.IsNullOrEmpty(testFilename)
                    || testFilename[0].Equals('-')
                    || testFilename[0].Equals('-')
                    || testFilename[0].Equals('_')
                    || testFilename[0].Equals('_')

+ 100 - 197
Emby.Server.Implementations/ApplicationHost.cs

@@ -1,14 +1,12 @@
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System;
 using System;
-using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Diagnostics;
 using System.Diagnostics;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
 using System.Net;
 using System.Net;
 using System.Net.Http;
 using System.Net.Http;
-using System.Net.Sockets;
 using System.Reflection;
 using System.Reflection;
 using System.Runtime.InteropServices;
 using System.Runtime.InteropServices;
 using System.Security.Cryptography.X509Certificates;
 using System.Security.Cryptography.X509Certificates;
@@ -46,10 +44,11 @@ using Emby.Server.Implementations.SyncPlay;
 using Emby.Server.Implementations.TV;
 using Emby.Server.Implementations.TV;
 using Emby.Server.Implementations.Updates;
 using Emby.Server.Implementations.Updates;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Networking.Configuration;
+using Jellyfin.Networking.Manager;
 using MediaBrowser.Common;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Events;
 using MediaBrowser.Common.Events;
-using MediaBrowser.Common.Json;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Common.Updates;
 using MediaBrowser.Common.Updates;
@@ -82,7 +81,6 @@ using MediaBrowser.Controller.SyncPlay;
 using MediaBrowser.Controller.TV;
 using MediaBrowser.Controller.TV;
 using MediaBrowser.LocalMetadata.Savers;
 using MediaBrowser.LocalMetadata.Savers;
 using MediaBrowser.MediaEncoding.BdInfo;
 using MediaBrowser.MediaEncoding.BdInfo;
-using MediaBrowser.Model.Configuration;
 using MediaBrowser.Model.Cryptography;
 using MediaBrowser.Model.Cryptography;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Globalization;
@@ -97,6 +95,7 @@ using MediaBrowser.Providers.Manager;
 using MediaBrowser.Providers.Plugins.Tmdb;
 using MediaBrowser.Providers.Plugins.Tmdb;
 using MediaBrowser.Providers.Subtitles;
 using MediaBrowser.Providers.Subtitles;
 using MediaBrowser.XbmcMetadata.Providers;
 using MediaBrowser.XbmcMetadata.Providers;
+using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
@@ -117,14 +116,12 @@ namespace Emby.Server.Implementations
         private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" };
         private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" };
 
 
         private readonly IFileSystem _fileSystemManager;
         private readonly IFileSystem _fileSystemManager;
-        private readonly INetworkManager _networkManager;
         private readonly IXmlSerializer _xmlSerializer;
         private readonly IXmlSerializer _xmlSerializer;
         private readonly IJsonSerializer _jsonSerializer;
         private readonly IJsonSerializer _jsonSerializer;
         private readonly IStartupOptions _startupOptions;
         private readonly IStartupOptions _startupOptions;
 
 
         private IMediaEncoder _mediaEncoder;
         private IMediaEncoder _mediaEncoder;
         private ISessionManager _sessionManager;
         private ISessionManager _sessionManager;
-        private IHttpClientFactory _httpClientFactory;
         private string[] _urlPrefixes;
         private string[] _urlPrefixes;
 
 
         /// <summary>
         /// <summary>
@@ -158,6 +155,11 @@ namespace Emby.Server.Implementations
             }
             }
         }
         }
 
 
+        /// <summary>
+        /// Gets the <see cref="INetworkManager"/> singleton instance.
+        /// </summary>
+        public INetworkManager NetManager { get; internal set; }
+
         /// <summary>
         /// <summary>
         /// Occurs when [has pending restart changed].
         /// Occurs when [has pending restart changed].
         /// </summary>
         /// </summary>
@@ -212,7 +214,7 @@ namespace Emby.Server.Implementations
         private readonly List<IDisposable> _disposableParts = new List<IDisposable>();
         private readonly List<IDisposable> _disposableParts = new List<IDisposable>();
 
 
         /// <summary>
         /// <summary>
-        /// Gets the configuration manager.
+        /// Gets or sets the configuration manager.
         /// </summary>
         /// </summary>
         /// <value>The configuration manager.</value>
         /// <value>The configuration manager.</value>
         protected IConfigurationManager ConfigurationManager { get; set; }
         protected IConfigurationManager ConfigurationManager { get; set; }
@@ -245,14 +247,12 @@ namespace Emby.Server.Implementations
         /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
         /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
         /// <param name="options">Instance of the <see cref="IStartupOptions"/> interface.</param>
         /// <param name="options">Instance of the <see cref="IStartupOptions"/> interface.</param>
         /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
         /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
         /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param>
         /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param>
         public ApplicationHost(
         public ApplicationHost(
             IServerApplicationPaths applicationPaths,
             IServerApplicationPaths applicationPaths,
             ILoggerFactory loggerFactory,
             ILoggerFactory loggerFactory,
             IStartupOptions options,
             IStartupOptions options,
             IFileSystem fileSystem,
             IFileSystem fileSystem,
-            INetworkManager networkManager,
             IServiceCollection serviceCollection)
             IServiceCollection serviceCollection)
         {
         {
             _xmlSerializer = new MyXmlSerializer();
             _xmlSerializer = new MyXmlSerializer();
@@ -260,14 +260,17 @@ namespace Emby.Server.Implementations
 
 
             ServiceCollection = serviceCollection;
             ServiceCollection = serviceCollection;
 
 
-            _networkManager = networkManager;
-            networkManager.LocalSubnetsFn = GetConfiguredLocalSubnets;
-
             ApplicationPaths = applicationPaths;
             ApplicationPaths = applicationPaths;
             LoggerFactory = loggerFactory;
             LoggerFactory = loggerFactory;
             _fileSystemManager = fileSystem;
             _fileSystemManager = fileSystem;
 
 
             ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager);
             ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager);
+            // Have to migrate settings here as migration subsystem not yet initialised.
+            MigrateNetworkConfiguration();
+
+            // Have to pre-register the NetworkConfigurationFactory, as the configuration sub-system is not yet initialised.
+            ConfigurationManager.RegisterConfiguration<NetworkConfigurationFactory>();
+            NetManager = new NetworkManager((IServerConfigurationManager)ConfigurationManager, LoggerFactory.CreateLogger<NetworkManager>());
 
 
             Logger = LoggerFactory.CreateLogger<ApplicationHost>();
             Logger = LoggerFactory.CreateLogger<ApplicationHost>();
 
 
@@ -281,8 +284,6 @@ namespace Emby.Server.Implementations
 
 
             fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem));
             fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem));
 
 
-            _networkManager.NetworkChanged += OnNetworkChanged;
-
             CertificateInfo = new CertificateInfo
             CertificateInfo = new CertificateInfo
             {
             {
                 Path = ServerConfigurationManager.Configuration.CertificatePath,
                 Path = ServerConfigurationManager.Configuration.CertificatePath,
@@ -295,6 +296,22 @@ namespace Emby.Server.Implementations
             ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString;
             ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString;
         }
         }
 
 
+        /// <summary>
+        /// Temporary function to migration network settings out of system.xml and into network.xml.
+        /// TODO: remove at the point when a fixed migration path has been decided upon.
+        /// </summary>
+        private void MigrateNetworkConfiguration()
+        {
+            string path = Path.Combine(ConfigurationManager.CommonApplicationPaths.ConfigurationDirectoryPath, "network.xml");
+            if (!File.Exists(path))
+            {
+                var networkSettings = new NetworkConfiguration();
+                ClassMigrationHelper.CopyProperties(ServerConfigurationManager.Configuration, networkSettings);
+                _xmlSerializer.SerializeToFile(networkSettings, path);
+                Logger?.LogDebug("Successfully migrated network settings.");
+            }
+        }
+
         public string ExpandVirtualPath(string path)
         public string ExpandVirtualPath(string path)
         {
         {
             var appPaths = ApplicationPaths;
             var appPaths = ApplicationPaths;
@@ -311,16 +328,6 @@ namespace Emby.Server.Implementations
                 .Replace(appPaths.InternalMetadataPath, appPaths.VirtualInternalMetadataPath, StringComparison.OrdinalIgnoreCase);
                 .Replace(appPaths.InternalMetadataPath, appPaths.VirtualInternalMetadataPath, StringComparison.OrdinalIgnoreCase);
         }
         }
 
 
-        private string[] GetConfiguredLocalSubnets()
-        {
-            return ServerConfigurationManager.Configuration.LocalNetworkSubnets;
-        }
-
-        private void OnNetworkChanged(object sender, EventArgs e)
-        {
-            _validAddressResults.Clear();
-        }
-
         /// <inheritdoc />
         /// <inheritdoc />
         public Version ApplicationVersion { get; }
         public Version ApplicationVersion { get; }
 
 
@@ -487,14 +494,15 @@ namespace Emby.Server.Implementations
         /// <inheritdoc/>
         /// <inheritdoc/>
         public void Init()
         public void Init()
         {
         {
-            HttpPort = ServerConfigurationManager.Configuration.HttpServerPortNumber;
-            HttpsPort = ServerConfigurationManager.Configuration.HttpsPortNumber;
+            var networkConfiguration = ServerConfigurationManager.GetNetworkConfiguration();
+            HttpPort = networkConfiguration.HttpServerPortNumber;
+            HttpsPort = networkConfiguration.HttpsPortNumber;
 
 
             // Safeguard against invalid configuration
             // Safeguard against invalid configuration
             if (HttpPort == HttpsPort)
             if (HttpPort == HttpsPort)
             {
             {
-                HttpPort = ServerConfiguration.DefaultHttpPort;
-                HttpsPort = ServerConfiguration.DefaultHttpsPort;
+                HttpPort = NetworkConfiguration.DefaultHttpPort;
+                HttpsPort = NetworkConfiguration.DefaultHttpsPort;
             }
             }
 
 
             DiscoverTypes();
             DiscoverTypes();
@@ -523,7 +531,7 @@ namespace Emby.Server.Implementations
             ServiceCollection.AddSingleton(_fileSystemManager);
             ServiceCollection.AddSingleton(_fileSystemManager);
             ServiceCollection.AddSingleton<TmdbClientManager>();
             ServiceCollection.AddSingleton<TmdbClientManager>();
 
 
-            ServiceCollection.AddSingleton(_networkManager);
+            ServiceCollection.AddSingleton(NetManager);
 
 
             ServiceCollection.AddSingleton<IIsoManager, IsoManager>();
             ServiceCollection.AddSingleton<IIsoManager, IsoManager>();
 
 
@@ -627,7 +635,6 @@ namespace Emby.Server.Implementations
 
 
             ServiceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>();
             ServiceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>();
 
 
-            ServiceCollection.AddSingleton<IResourceFileManager, ResourceFileManager>();
             ServiceCollection.AddSingleton<EncodingHelper>();
             ServiceCollection.AddSingleton<EncodingHelper>();
 
 
             ServiceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>();
             ServiceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>();
@@ -649,7 +656,6 @@ namespace Emby.Server.Implementations
 
 
             _mediaEncoder = Resolve<IMediaEncoder>();
             _mediaEncoder = Resolve<IMediaEncoder>();
             _sessionManager = Resolve<ISessionManager>();
             _sessionManager = Resolve<ISessionManager>();
-            _httpClientFactory = Resolve<IHttpClientFactory>();
 
 
             ((AuthenticationRepository)Resolve<IAuthenticationRepository>()).Initialize();
             ((AuthenticationRepository)Resolve<IAuthenticationRepository>()).Initialize();
 
 
@@ -770,8 +776,6 @@ namespace Emby.Server.Implementations
 
 
             if (Plugins != null)
             if (Plugins != null)
             {
             {
-                var pluginBuilder = new StringBuilder();
-
                 foreach (var plugin in Plugins)
                 foreach (var plugin in Plugins)
                 {
                 {
                     if (_pluginsManifests != null && plugin is IPluginAssembly assemblyPlugin)
                     if (_pluginsManifests != null && plugin is IPluginAssembly assemblyPlugin)
@@ -791,13 +795,8 @@ namespace Emby.Server.Implementations
                         }
                         }
                     }
                     }
 
 
-                    pluginBuilder.Append(plugin.Name)
-                        .Append(' ')
-                        .Append(plugin.Version)
-                        .AppendLine();
+                    Logger.LogInformation("Loaded plugin: {PluginName} {PluginVersion}", plugin.Name, plugin.Version);
                 }
                 }
-
-                Logger.LogInformation("Plugins: {Plugins}", pluginBuilder.ToString());
             }
             }
 
 
             _urlPrefixes = GetUrlPrefixes().ToArray();
             _urlPrefixes = GetUrlPrefixes().ToArray();
@@ -921,9 +920,10 @@ namespace Emby.Server.Implementations
             // Don't do anything if these haven't been set yet
             // Don't do anything if these haven't been set yet
             if (HttpPort != 0 && HttpsPort != 0)
             if (HttpPort != 0 && HttpsPort != 0)
             {
             {
+                var networkConfiguration = ServerConfigurationManager.GetNetworkConfiguration();
                 // Need to restart if ports have changed
                 // Need to restart if ports have changed
-                if (ServerConfigurationManager.Configuration.HttpServerPortNumber != HttpPort ||
-                    ServerConfigurationManager.Configuration.HttpsPortNumber != HttpsPort)
+                if (networkConfiguration.HttpServerPortNumber != HttpPort ||
+                    networkConfiguration.HttpsPortNumber != HttpsPort)
                 {
                 {
                     if (ServerConfigurationManager.Configuration.IsPortAuthorized)
                     if (ServerConfigurationManager.Configuration.IsPortAuthorized)
                     {
                     {
@@ -1053,7 +1053,7 @@ namespace Emby.Server.Implementations
                         metafile = dir.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)[^1];
                         metafile = dir.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)[^1];
 
 
                         int versionIndex = dir.LastIndexOf('_');
                         int versionIndex = dir.LastIndexOf('_');
-                        if (versionIndex != -1 && Version.TryParse(dir.Substring(versionIndex + 1), out Version parsedVersion))
+                        if (versionIndex != -1 && Version.TryParse(dir.AsSpan()[(versionIndex + 1)..], out Version parsedVersion))
                         {
                         {
                             // Versioned folder.
                             // Versioned folder.
                             versions.Add(new LocalPlugin(Guid.Empty, metafile, parsedVersion, dir));
                             versions.Add(new LocalPlugin(Guid.Empty, metafile, parsedVersion, dir));
@@ -1167,6 +1167,9 @@ namespace Emby.Server.Implementations
             // Xbmc
             // Xbmc
             yield return typeof(ArtistNfoProvider).Assembly;
             yield return typeof(ArtistNfoProvider).Assembly;
 
 
+            // Network
+            yield return typeof(NetworkManager).Assembly;
+
             foreach (var i in GetAssembliesWithPartsInternal())
             foreach (var i in GetAssembliesWithPartsInternal())
             {
             {
                 yield return i;
                 yield return i;
@@ -1178,13 +1181,10 @@ namespace Emby.Server.Implementations
         /// <summary>
         /// <summary>
         /// Gets the system status.
         /// Gets the system status.
         /// </summary>
         /// </summary>
-        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <param name="source">Where this request originated.</param>
         /// <returns>SystemInfo.</returns>
         /// <returns>SystemInfo.</returns>
-        public async Task<SystemInfo> GetSystemInfo(CancellationToken cancellationToken)
+        public SystemInfo GetSystemInfo(IPAddress source)
         {
         {
-            var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
-            var transcodingTempPath = ConfigurationManager.GetTranscodePath();
-
             return new SystemInfo
             return new SystemInfo
             {
             {
                 HasPendingRestart = HasPendingRestart,
                 HasPendingRestart = HasPendingRestart,
@@ -1204,9 +1204,9 @@ namespace Emby.Server.Implementations
                 CanSelfRestart = CanSelfRestart,
                 CanSelfRestart = CanSelfRestart,
                 CanLaunchWebBrowser = CanLaunchWebBrowser,
                 CanLaunchWebBrowser = CanLaunchWebBrowser,
                 HasUpdateAvailable = HasUpdateAvailable,
                 HasUpdateAvailable = HasUpdateAvailable,
-                TranscodingTempPath = transcodingTempPath,
+                TranscodingTempPath = ConfigurationManager.GetTranscodePath(),
                 ServerName = FriendlyName,
                 ServerName = FriendlyName,
-                LocalAddress = localAddress,
+                LocalAddress = GetSmartApiUrl(source),
                 SupportsLibraryMonitor = true,
                 SupportsLibraryMonitor = true,
                 EncoderLocation = _mediaEncoder.EncoderLocation,
                 EncoderLocation = _mediaEncoder.EncoderLocation,
                 SystemArchitecture = RuntimeInformation.OSArchitecture,
                 SystemArchitecture = RuntimeInformation.OSArchitecture,
@@ -1215,14 +1215,12 @@ namespace Emby.Server.Implementations
         }
         }
 
 
         public IEnumerable<WakeOnLanInfo> GetWakeOnLanInfo()
         public IEnumerable<WakeOnLanInfo> GetWakeOnLanInfo()
-            => _networkManager.GetMacAddresses()
+            => NetManager.GetMacAddresses()
                 .Select(i => new WakeOnLanInfo(i))
                 .Select(i => new WakeOnLanInfo(i))
                 .ToList();
                 .ToList();
 
 
-        public async Task<PublicSystemInfo> GetPublicSystemInfo(CancellationToken cancellationToken)
+        public PublicSystemInfo GetPublicSystemInfo(IPAddress source)
         {
         {
-            var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
-
             return new PublicSystemInfo
             return new PublicSystemInfo
             {
             {
                 Version = ApplicationVersionString,
                 Version = ApplicationVersionString,
@@ -1230,193 +1228,98 @@ namespace Emby.Server.Implementations
                 Id = SystemId,
                 Id = SystemId,
                 OperatingSystem = OperatingSystem.Id.ToString(),
                 OperatingSystem = OperatingSystem.Id.ToString(),
                 ServerName = FriendlyName,
                 ServerName = FriendlyName,
-                LocalAddress = localAddress,
+                LocalAddress = GetSmartApiUrl(source),
                 StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted
                 StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted
             };
             };
         }
         }
 
 
         /// <inheritdoc/>
         /// <inheritdoc/>
-        public bool ListenWithHttps => Certificate != null && ServerConfigurationManager.Configuration.EnableHttps;
+        public bool ListenWithHttps => Certificate != null && ServerConfigurationManager.GetNetworkConfiguration().EnableHttps;
 
 
         /// <inheritdoc/>
         /// <inheritdoc/>
-        public async Task<string> GetLocalApiUrl(CancellationToken cancellationToken)
+        public string GetSmartApiUrl(IPAddress ipAddress, int? port = null)
         {
         {
-            try
+            // Published server ends with a /
+            if (_startupOptions.PublishedServerUrl != null)
             {
             {
-                // Return the first matched address, if found, or the first known local address
-                var addresses = await GetLocalIpAddressesInternal(false, 1, cancellationToken).ConfigureAwait(false);
-                if (addresses.Count == 0)
-                {
-                    return null;
-                }
-
-                return GetLocalApiUrl(addresses[0]);
+                // Published server ends with a '/', so we need to remove it.
+                return _startupOptions.PublishedServerUrl.ToString().Trim('/');
             }
             }
-            catch (Exception ex)
+
+            string smart = NetManager.GetBindInterface(ipAddress, out port);
+            // If the smartAPI doesn't start with http then treat it as a host or ip.
+            if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase))
             {
             {
-                Logger.LogError(ex, "Error getting local Ip address information");
+                return smart.Trim('/');
             }
             }
 
 
-            return null;
+            return GetLocalApiUrl(smart.Trim('/'), null, port);
         }
         }
 
 
-        /// <summary>
-        /// Removes the scope id from IPv6 addresses.
-        /// </summary>
-        /// <param name="address">The IPv6 address.</param>
-        /// <returns>The IPv6 address without the scope id.</returns>
-        private ReadOnlySpan<char> RemoveScopeId(ReadOnlySpan<char> address)
+        /// <inheritdoc/>
+        public string GetSmartApiUrl(HttpRequest request, int? port = null)
         {
         {
-            var index = address.IndexOf('%');
-            if (index == -1)
+            // Published server ends with a /
+            if (_startupOptions.PublishedServerUrl != null)
             {
             {
-                return address;
+                // Published server ends with a '/', so we need to remove it.
+                return _startupOptions.PublishedServerUrl.ToString().Trim('/');
             }
             }
 
 
-            return address.Slice(0, index);
-        }
-
-        /// <inheritdoc />
-        public string GetLocalApiUrl(IPAddress ipAddress)
-        {
-            if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6)
+            string smart = NetManager.GetBindInterface(request, out port);
+            // If the smartAPI doesn't start with http then treat it as a host or ip.
+            if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase))
             {
             {
-                var str = RemoveScopeId(ipAddress.ToString());
-                Span<char> span = new char[str.Length + 2];
-                span[0] = '[';
-                str.CopyTo(span.Slice(1));
-                span[^1] = ']';
-
-                return GetLocalApiUrl(span);
+                return smart.Trim('/');
             }
             }
 
 
-            return GetLocalApiUrl(ipAddress.ToString());
+            return GetLocalApiUrl(smart.Trim('/'), request.Scheme, port);
         }
         }
 
 
         /// <inheritdoc/>
         /// <inheritdoc/>
-        public string GetLoopbackHttpApiUrl()
-        {
-            return GetLocalApiUrl("127.0.0.1", Uri.UriSchemeHttp, HttpPort);
-        }
-
-        /// <inheritdoc/>
-        public string GetLocalApiUrl(ReadOnlySpan<char> host, string scheme = null, int? port = null)
-        {
-            // NOTE: If no BaseUrl is set then UriBuilder appends a trailing slash, but if there is no BaseUrl it does
-            // not. For consistency, always trim the trailing slash.
-            return new UriBuilder
-            {
-                Scheme = scheme ?? (ListenWithHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp),
-                Host = host.ToString(),
-                Port = port ?? (ListenWithHttps ? HttpsPort : HttpPort),
-                Path = ServerConfigurationManager.Configuration.BaseUrl
-            }.ToString().TrimEnd('/');
-        }
-
-        public Task<List<IPAddress>> GetLocalIpAddresses(CancellationToken cancellationToken)
+        public string GetSmartApiUrl(string hostname, int? port = null)
         {
         {
-            return GetLocalIpAddressesInternal(true, 0, cancellationToken);
-        }
-
-        private async Task<List<IPAddress>> GetLocalIpAddressesInternal(bool allowLoopback, int limit, CancellationToken cancellationToken)
-        {
-            var addresses = ServerConfigurationManager
-                .Configuration
-                .LocalNetworkAddresses
-                .Select(x => NormalizeConfiguredLocalAddress(x))
-                .Where(i => i != null)
-                .ToList();
-
-            if (addresses.Count == 0)
+            // Published server ends with a /
+            if (_startupOptions.PublishedServerUrl != null)
             {
             {
-                addresses.AddRange(_networkManager.GetLocalIpAddresses());
+                // Published server ends with a '/', so we need to remove it.
+                return _startupOptions.PublishedServerUrl.ToString().Trim('/');
             }
             }
 
 
-            var resultList = new List<IPAddress>();
+            string smart = NetManager.GetBindInterface(hostname, out port);
 
 
-            foreach (var address in addresses)
+            // If the smartAPI doesn't start with http then treat it as a host or ip.
+            if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase))
             {
             {
-                if (!allowLoopback)
-                {
-                    if (address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback))
-                    {
-                        continue;
-                    }
-                }
-
-                if (await IsLocalIpAddressValidAsync(address, cancellationToken).ConfigureAwait(false))
-                {
-                    resultList.Add(address);
-
-                    if (limit > 0 && resultList.Count >= limit)
-                    {
-                        return resultList;
-                    }
-                }
+                return smart.Trim('/');
             }
             }
 
 
-            return resultList;
+            return GetLocalApiUrl(smart.Trim('/'), null, port);
         }
         }
 
 
-        public IPAddress NormalizeConfiguredLocalAddress(ReadOnlySpan<char> address)
+        /// <inheritdoc/>
+        public string GetLoopbackHttpApiUrl()
         {
         {
-            var index = address.Trim('/').IndexOf('/');
-            if (index != -1)
-            {
-                address = address.Slice(index + 1);
-            }
-
-            if (IPAddress.TryParse(address.Trim('/'), out IPAddress result))
+            if (NetManager.IsIP6Enabled)
             {
             {
-                return result;
+                return GetLocalApiUrl("::1", Uri.UriSchemeHttp, HttpPort);
             }
             }
 
 
-            return null;
+            return GetLocalApiUrl("127.0.0.1", Uri.UriSchemeHttp, HttpPort);
         }
         }
 
 
-        private readonly ConcurrentDictionary<string, bool> _validAddressResults = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
-
-        private async Task<bool> IsLocalIpAddressValidAsync(IPAddress address, CancellationToken cancellationToken)
+        /// <inheritdoc/>
+        public string GetLocalApiUrl(string host, string scheme = null, int? port = null)
         {
         {
-            if (address.Equals(IPAddress.Loopback)
-                || address.Equals(IPAddress.IPv6Loopback))
-            {
-                return true;
-            }
-
-            var apiUrl = GetLocalApiUrl(address) + "/system/ping";
-
-            if (_validAddressResults.TryGetValue(apiUrl, out var cachedResult))
-            {
-                return cachedResult;
-            }
-
-            try
-            {
-                using var request = new HttpRequestMessage(HttpMethod.Post, apiUrl);
-                using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
-                    .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
-
-                await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-                var result = await System.Text.Json.JsonSerializer.DeserializeAsync<string>(stream, JsonDefaults.GetOptions(), cancellationToken).ConfigureAwait(false);
-                var valid = string.Equals(Name, result, StringComparison.OrdinalIgnoreCase);
-
-                _validAddressResults.AddOrUpdate(apiUrl, valid, (k, v) => valid);
-                Logger.LogDebug("Ping test result to {0}. Success: {1}", apiUrl, valid);
-                return valid;
-            }
-            catch (OperationCanceledException)
-            {
-                Logger.LogDebug("Ping test result to {0}. Success: {1}", apiUrl, "Cancelled");
-                throw;
-            }
-            catch (Exception ex)
+            // NOTE: If no BaseUrl is set then UriBuilder appends a trailing slash, but if there is no BaseUrl it does
+            // not. For consistency, always trim the trailing slash.
+            return new UriBuilder
             {
             {
-                Logger.LogDebug(ex, "Ping test result to {0}. Success: {1}", apiUrl, false);
-
-                _validAddressResults.AddOrUpdate(apiUrl, false, (k, v) => false);
-                return false;
-            }
+                Scheme = scheme ?? (ListenWithHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp),
+                Host = host,
+                Port = port ?? (ListenWithHttps ? HttpsPort : HttpPort),
+                Path = ServerConfigurationManager.GetNetworkConfiguration().BaseUrl
+            }.ToString().TrimEnd('/');
         }
         }
 
 
         public string FriendlyName =>
         public string FriendlyName =>

+ 5 - 5
Emby.Server.Implementations/Channels/ChannelManager.cs

@@ -540,18 +540,18 @@ namespace Emby.Server.Implementations.Channels
                 {
                 {
                     IncludeItemTypes = new[] { nameof(Channel) },
                     IncludeItemTypes = new[] { nameof(Channel) },
                     OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }
                     OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }
-                }).Select(i => GetChannelFeatures(i.ToString("N", CultureInfo.InvariantCulture))).ToArray();
+                }).Select(i => GetChannelFeatures(i)).ToArray();
         }
         }
 
 
         /// <inheritdoc />
         /// <inheritdoc />
-        public ChannelFeatures GetChannelFeatures(string id)
+        public ChannelFeatures GetChannelFeatures(Guid? id)
         {
         {
-            if (string.IsNullOrEmpty(id))
+            if (!id.HasValue)
             {
             {
                 throw new ArgumentNullException(nameof(id));
                 throw new ArgumentNullException(nameof(id));
             }
             }
 
 
-            var channel = GetChannel(id);
+            var channel = GetChannel(id.Value);
             var channelProvider = GetChannelProvider(channel);
             var channelProvider = GetChannelProvider(channel);
 
 
             return GetChannelFeaturesDto(channel, channelProvider, channelProvider.GetChannelFeatures());
             return GetChannelFeaturesDto(channel, channelProvider, channelProvider.GetChannelFeatures());
@@ -634,7 +634,7 @@ namespace Emby.Server.Implementations.Channels
         {
         {
             var channels = GetAllChannels().Where(i => i is ISupportsLatestMedia).ToArray();
             var channels = GetAllChannels().Where(i => i is ISupportsLatestMedia).ToArray();
 
 
-            if (query.ChannelIds.Length > 0)
+            if (query.ChannelIds.Count > 0)
             {
             {
                 // Avoid implicitly captured closure
                 // Avoid implicitly captured closure
                 var ids = query.ChannelIds;
                 var ids = query.ChannelIds;

+ 22 - 7
Emby.Server.Implementations/Data/SqliteItemRepository.cs

@@ -3611,12 +3611,12 @@ namespace Emby.Server.Implementations.Data
                 whereClauses.Add($"type in ({inClause})");
                 whereClauses.Add($"type in ({inClause})");
             }
             }
 
 
-            if (query.ChannelIds.Length == 1)
+            if (query.ChannelIds.Count == 1)
             {
             {
                 whereClauses.Add("ChannelId=@ChannelId");
                 whereClauses.Add("ChannelId=@ChannelId");
                 statement?.TryBind("@ChannelId", query.ChannelIds[0].ToString("N", CultureInfo.InvariantCulture));
                 statement?.TryBind("@ChannelId", query.ChannelIds[0].ToString("N", CultureInfo.InvariantCulture));
             }
             }
-            else if (query.ChannelIds.Length > 1)
+            else if (query.ChannelIds.Count > 1)
             {
             {
                 var inClause = string.Join(",", query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'"));
                 var inClause = string.Join(",", query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'"));
                 whereClauses.Add($"ChannelId in ({inClause})");
                 whereClauses.Add($"ChannelId in ({inClause})");
@@ -4076,7 +4076,7 @@ namespace Emby.Server.Implementations.Data
                 whereClauses.Add(clause);
                 whereClauses.Add(clause);
             }
             }
 
 
-            if (query.GenreIds.Length > 0)
+            if (query.GenreIds.Count > 0)
             {
             {
                 var clauses = new List<string>();
                 var clauses = new List<string>();
                 var index = 0;
                 var index = 0;
@@ -4097,7 +4097,7 @@ namespace Emby.Server.Implementations.Data
                 whereClauses.Add(clause);
                 whereClauses.Add(clause);
             }
             }
 
 
-            if (query.Genres.Length > 0)
+            if (query.Genres.Count > 0)
             {
             {
                 var clauses = new List<string>();
                 var clauses = new List<string>();
                 var index = 0;
                 var index = 0;
@@ -4519,17 +4519,17 @@ namespace Emby.Server.Implementations.Data
 
 
             if (query.HasImdbId.HasValue)
             if (query.HasImdbId.HasValue)
             {
             {
-                whereClauses.Add("ProviderIds like '%imdb=%'");
+                whereClauses.Add(GetProviderIdClause(query.HasImdbId.Value, "imdb"));
             }
             }
 
 
             if (query.HasTmdbId.HasValue)
             if (query.HasTmdbId.HasValue)
             {
             {
-                whereClauses.Add("ProviderIds like '%tmdb=%'");
+                whereClauses.Add(GetProviderIdClause(query.HasTmdbId.Value, "tmdb"));
             }
             }
 
 
             if (query.HasTvdbId.HasValue)
             if (query.HasTvdbId.HasValue)
             {
             {
-                whereClauses.Add("ProviderIds like '%tvdb=%'");
+                whereClauses.Add(GetProviderIdClause(query.HasTvdbId.Value, "tvdb"));
             }
             }
 
 
             var includedItemByNameTypes = GetItemByNameTypesInQuery(query).SelectMany(MapIncludeItemTypes).ToList();
             var includedItemByNameTypes = GetItemByNameTypesInQuery(query).SelectMany(MapIncludeItemTypes).ToList();
@@ -4769,6 +4769,21 @@ namespace Emby.Server.Implementations.Data
             return whereClauses;
             return whereClauses;
         }
         }
 
 
+        /// <summary>
+        /// Formats a where clause for the specified provider.
+        /// </summary>
+        /// <param name="includeResults">Whether or not to include items with this provider's ids.</param>
+        /// <param name="provider">Provider name.</param>
+        /// <returns>Formatted SQL clause.</returns>
+        private string GetProviderIdClause(bool includeResults, string provider)
+        {
+            return string.Format(
+                CultureInfo.InvariantCulture,
+                "ProviderIds {0} like '%{1}=%'",
+                includeResults ? string.Empty : "not",
+                provider);
+        }
+
         private List<string> GetItemByNameTypesInQuery(InternalItemsQuery query)
         private List<string> GetItemByNameTypesInQuery(InternalItemsQuery query)
         {
         {
             var list = new List<string>();
             var list = new List<string>();

+ 9 - 62
Emby.Server.Implementations/Devices/DeviceManager.cs

@@ -1,61 +1,38 @@
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System;
 using System;
+using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Events;
 using Jellyfin.Data.Events;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Security;
 using MediaBrowser.Controller.Security;
 using MediaBrowser.Model.Devices;
 using MediaBrowser.Model.Devices;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Session;
 using MediaBrowser.Model.Session;
-using Microsoft.Extensions.Caching.Memory;
 
 
 namespace Emby.Server.Implementations.Devices
 namespace Emby.Server.Implementations.Devices
 {
 {
     public class DeviceManager : IDeviceManager
     public class DeviceManager : IDeviceManager
     {
     {
-        private readonly IMemoryCache _memoryCache;
-        private readonly IJsonSerializer _json;
         private readonly IUserManager _userManager;
         private readonly IUserManager _userManager;
-        private readonly IServerConfigurationManager _config;
         private readonly IAuthenticationRepository _authRepo;
         private readonly IAuthenticationRepository _authRepo;
-        private readonly object _capabilitiesSyncLock = new object();
+        private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new ();
 
 
-        public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
-
-        public DeviceManager(
-            IAuthenticationRepository authRepo,
-            IJsonSerializer json,
-            IUserManager userManager,
-            IServerConfigurationManager config,
-            IMemoryCache memoryCache)
+        public DeviceManager(IAuthenticationRepository authRepo, IUserManager userManager)
         {
         {
-            _json = json;
             _userManager = userManager;
             _userManager = userManager;
-            _config = config;
-            _memoryCache = memoryCache;
             _authRepo = authRepo;
             _authRepo = authRepo;
         }
         }
 
 
+        public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
+
         public void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
         public void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
         {
         {
-            var path = Path.Combine(GetDevicePath(deviceId), "capabilities.json");
-            Directory.CreateDirectory(Path.GetDirectoryName(path));
-
-            lock (_capabilitiesSyncLock)
-            {
-                _memoryCache.Set(deviceId, capabilities);
-                _json.SerializeToFile(capabilities, path);
-            }
+            _capabilitiesMap[deviceId] = capabilities;
         }
         }
 
 
         public void UpdateDeviceOptions(string deviceId, DeviceOptions options)
         public void UpdateDeviceOptions(string deviceId, DeviceOptions options)
@@ -72,32 +49,12 @@ namespace Emby.Server.Implementations.Devices
 
 
         public ClientCapabilities GetCapabilities(string id)
         public ClientCapabilities GetCapabilities(string id)
         {
         {
-            if (_memoryCache.TryGetValue(id, out ClientCapabilities result))
-            {
-                return result;
-            }
-
-            lock (_capabilitiesSyncLock)
-            {
-                var path = Path.Combine(GetDevicePath(id), "capabilities.json");
-                try
-                {
-                    return _json.DeserializeFromFile<ClientCapabilities>(path) ?? new ClientCapabilities();
-                }
-                catch
-                {
-                }
-            }
-
-            return new ClientCapabilities();
+            return _capabilitiesMap.TryGetValue(id, out ClientCapabilities result)
+                ? result
+                : new ClientCapabilities();
         }
         }
 
 
         public DeviceInfo GetDevice(string id)
         public DeviceInfo GetDevice(string id)
-        {
-            return GetDevice(id, true);
-        }
-
-        private DeviceInfo GetDevice(string id, bool includeCapabilities)
         {
         {
             var session = _authRepo.Get(new AuthenticationInfoQuery
             var session = _authRepo.Get(new AuthenticationInfoQuery
             {
             {
@@ -154,16 +111,6 @@ namespace Emby.Server.Implementations.Devices
             };
             };
         }
         }
 
 
-        private string GetDevicesPath()
-        {
-            return Path.Combine(_config.ApplicationPaths.DataPath, "devices");
-        }
-
-        private string GetDevicePath(string id)
-        {
-            return Path.Combine(GetDevicesPath(), id.GetMD5().ToString("N", CultureInfo.InvariantCulture));
-        }
-
         public bool CanAccessDevice(User user, string deviceId)
         public bool CanAccessDevice(User user, string deviceId)
         {
         {
             if (user == null)
             if (user == null)

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

@@ -22,7 +22,6 @@
   </ItemGroup>
   </ItemGroup>
 
 
   <ItemGroup>
   <ItemGroup>
-    <PackageReference Include="IPNetwork2" Version="2.5.226" />
     <PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" />
     <PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" />
     <PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.2.7" />
     <PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.2.7" />
     <PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" />
     <PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" />
@@ -37,8 +36,8 @@
     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
     <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" />
     <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" />
     <PackageReference Include="Mono.Nat" Version="3.0.1" />
     <PackageReference Include="Mono.Nat" Version="3.0.1" />
-    <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.4.0" />
-    <PackageReference Include="ServiceStack.Text.Core" Version="5.10.0" />
+    <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.4.1" />
+    <PackageReference Include="ServiceStack.Text.Core" Version="5.10.2" />
     <PackageReference Include="sharpcompress" Version="0.26.0" />
     <PackageReference Include="sharpcompress" Version="0.26.0" />
     <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" />
     <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" />
     <PackageReference Include="DotNet.Glob" Version="3.1.0" />
     <PackageReference Include="DotNet.Glob" Version="3.1.0" />

+ 7 - 4
Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs

@@ -8,6 +8,7 @@ using System.Text;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Data.Events;
 using Jellyfin.Data.Events;
+using Jellyfin.Networking.Configuration;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Plugins;
 using MediaBrowser.Controller.Plugins;
@@ -56,7 +57,7 @@ namespace Emby.Server.Implementations.EntryPoints
         private string GetConfigIdentifier()
         private string GetConfigIdentifier()
         {
         {
             const char Separator = '|';
             const char Separator = '|';
-            var config = _config.Configuration;
+            var config = _config.GetNetworkConfiguration();
 
 
             return new StringBuilder(32)
             return new StringBuilder(32)
                 .Append(config.EnableUPnP).Append(Separator)
                 .Append(config.EnableUPnP).Append(Separator)
@@ -93,7 +94,8 @@ namespace Emby.Server.Implementations.EntryPoints
 
 
         private void Start()
         private void Start()
         {
         {
-            if (!_config.Configuration.EnableUPnP || !_config.Configuration.EnableRemoteAccess)
+            var config = _config.GetNetworkConfiguration();
+            if (!config.EnableUPnP || !config.EnableRemoteAccess)
             {
             {
                 return;
                 return;
             }
             }
@@ -156,11 +158,12 @@ namespace Emby.Server.Implementations.EntryPoints
 
 
         private IEnumerable<Task> CreatePortMaps(INatDevice device)
         private IEnumerable<Task> CreatePortMaps(INatDevice device)
         {
         {
-            yield return CreatePortMap(device, _appHost.HttpPort, _config.Configuration.PublicPort);
+            var config = _config.GetNetworkConfiguration();
+            yield return CreatePortMap(device, _appHost.HttpPort, config.PublicPort);
 
 
             if (_appHost.ListenWithHttps)
             if (_appHost.ListenWithHttps)
             {
             {
-                yield return CreatePortMap(device, _appHost.HttpsPort, _config.Configuration.PublicHttpsPort);
+                yield return CreatePortMap(device, _appHost.HttpsPort, config.PublicHttpsPort);
             }
             }
         }
         }
 
 

+ 5 - 2
Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs

@@ -1,6 +1,7 @@
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using System;
 using System;
+using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Globalization;
 using System.Linq;
 using System.Linq;
@@ -44,7 +45,7 @@ namespace Emby.Server.Implementations.EntryPoints
         private readonly List<BaseItem> _itemsAdded = new List<BaseItem>();
         private readonly List<BaseItem> _itemsAdded = new List<BaseItem>();
         private readonly List<BaseItem> _itemsRemoved = new List<BaseItem>();
         private readonly List<BaseItem> _itemsRemoved = new List<BaseItem>();
         private readonly List<BaseItem> _itemsUpdated = new List<BaseItem>();
         private readonly List<BaseItem> _itemsUpdated = new List<BaseItem>();
-        private readonly Dictionary<Guid, DateTime> _lastProgressMessageTimes = new Dictionary<Guid, DateTime>();
+        private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new ConcurrentDictionary<Guid, DateTime>();
 
 
         public LibraryChangedNotifier(
         public LibraryChangedNotifier(
             ILibraryManager libraryManager,
             ILibraryManager libraryManager,
@@ -98,7 +99,7 @@ namespace Emby.Server.Implementations.EntryPoints
                 }
                 }
             }
             }
 
 
-            _lastProgressMessageTimes[item.Id] = DateTime.UtcNow;
+            _lastProgressMessageTimes.AddOrUpdate(item.Id, key => DateTime.UtcNow, (key, existing) => DateTime.UtcNow);
 
 
             var dict = new Dictionary<string, string>();
             var dict = new Dictionary<string, string>();
             dict["ItemId"] = item.Id.ToString("N", CultureInfo.InvariantCulture);
             dict["ItemId"] = item.Id.ToString("N", CultureInfo.InvariantCulture);
@@ -140,6 +141,8 @@ namespace Emby.Server.Implementations.EntryPoints
         private void OnProviderRefreshCompleted(object sender, GenericEventArgs<BaseItem> e)
         private void OnProviderRefreshCompleted(object sender, GenericEventArgs<BaseItem> e)
         {
         {
             OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100)));
             OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100)));
+
+            _lastProgressMessageTimes.TryRemove(e.Argument.Id, out DateTime removed);
         }
         }
 
 
         private static bool EnableRefreshMessage(BaseItem item)
         private static bool EnableRefreshMessage(BaseItem item)

+ 8 - 1
Emby.Server.Implementations/HttpServer/Security/AuthService.cs

@@ -1,5 +1,6 @@
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
+using System;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
@@ -20,9 +21,15 @@ namespace Emby.Server.Implementations.HttpServer.Security
         public AuthorizationInfo Authenticate(HttpRequest request)
         public AuthorizationInfo Authenticate(HttpRequest request)
         {
         {
             var auth = _authorizationContext.GetAuthorizationInfo(request);
             var auth = _authorizationContext.GetAuthorizationInfo(request);
+
+            if (!auth.HasToken)
+            {
+                throw new AuthenticationException("Request does not contain a token.");
+            }
+
             if (!auth.IsAuthenticated)
             if (!auth.IsAuthenticated)
             {
             {
-                throw new AuthenticationException("Invalid token.");
+                throw new SecurityException("Invalid token.");
             }
             }
 
 
             if (auth.User?.HasPermission(PermissionKind.IsDisabled) ?? false)
             if (auth.User?.HasPermission(PermissionKind.IsDisabled) ?? false)

+ 3 - 1
Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs

@@ -102,7 +102,8 @@ namespace Emby.Server.Implementations.HttpServer.Security
                 DeviceId = deviceId,
                 DeviceId = deviceId,
                 Version = version,
                 Version = version,
                 Token = token,
                 Token = token,
-                IsAuthenticated = false
+                IsAuthenticated = false,
+                HasToken = false
             };
             };
 
 
             if (string.IsNullOrWhiteSpace(token))
             if (string.IsNullOrWhiteSpace(token))
@@ -111,6 +112,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
                 return authInfo;
                 return authInfo;
             }
             }
 
 
+            authInfo.HasToken = true;
             var result = _authRepo.Get(new AuthenticationInfoQuery
             var result = _authRepo.Get(new AuthenticationInfoQuery
             {
             {
                 AccessToken = token
                 AccessToken = token

+ 130 - 0
Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs

@@ -0,0 +1,130 @@
+using System;
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Events;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Net;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.Library
+{
+    /// <summary>
+    /// A library post scan/refresh task for pre-fetching remote images.
+    /// </summary>
+    public class ImageFetcherPostScanTask : ILibraryPostScanTask
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly IProviderManager _providerManager;
+        private readonly ILogger<ImageFetcherPostScanTask> _logger;
+        private readonly SemaphoreSlim _imageFetcherLock;
+
+        private ConcurrentDictionary<Guid, (BaseItem item, ItemUpdateType updateReason)> _queuedItems;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ImageFetcherPostScanTask"/> class.
+        /// </summary>
+        /// <param name="libraryManager">An instance of <see cref="ILibraryManager"/>.</param>
+        /// <param name="providerManager">An instance of <see cref="IProviderManager"/>.</param>
+        /// <param name="logger">An instance of <see cref="ILogger{ImageFetcherPostScanTask}"/>.</param>
+        public ImageFetcherPostScanTask(
+            ILibraryManager libraryManager,
+            IProviderManager providerManager,
+            ILogger<ImageFetcherPostScanTask> logger)
+        {
+            _libraryManager = libraryManager;
+            _providerManager = providerManager;
+            _logger = logger;
+            _queuedItems = new ConcurrentDictionary<Guid, (BaseItem item, ItemUpdateType updateReason)>();
+            _imageFetcherLock = new SemaphoreSlim(1, 1);
+            _libraryManager.ItemAdded += OnLibraryManagerItemAddedOrUpdated;
+            _libraryManager.ItemUpdated += OnLibraryManagerItemAddedOrUpdated;
+            _providerManager.RefreshCompleted += OnProviderManagerRefreshCompleted;
+        }
+
+        /// <inheritdoc />
+        public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+        {
+            // Sometimes a library scan will cause this to run twice if there's an item refresh going on.
+            await _imageFetcherLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+            try
+            {
+                var now = DateTime.UtcNow;
+                var itemGuids = _queuedItems.Keys.ToList();
+
+                for (var i = 0; i < itemGuids.Count; i++)
+                {
+                    if (!_queuedItems.TryGetValue(itemGuids[i], out var queuedItem))
+                    {
+                        continue;
+                    }
+
+                    var itemId = queuedItem.item.Id.ToString("N", CultureInfo.InvariantCulture);
+                    var itemType = queuedItem.item.GetType();
+                    _logger.LogDebug(
+                        "Updating remote images for item {ItemId} with media type {ItemMediaType}",
+                        itemId,
+                        itemType);
+                    try
+                    {
+                        await _libraryManager.UpdateImagesAsync(queuedItem.item, queuedItem.updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false);
+                    }
+                    catch (Exception ex)
+                    {
+                        _logger.LogError(ex, "Failed to fetch images for {Type} item with id {ItemId}", itemType, itemId);
+                    }
+
+                    _queuedItems.TryRemove(queuedItem.item.Id, out _);
+                }
+
+                if (itemGuids.Count > 0)
+                {
+                    _logger.LogInformation(
+                        "Finished updating/pre-fetching {NumberOfImages} images. Elapsed time: {TimeElapsed}s.",
+                        itemGuids.Count.ToString(CultureInfo.InvariantCulture),
+                        (DateTime.UtcNow - now).TotalSeconds.ToString(CultureInfo.InvariantCulture));
+                }
+                else
+                {
+                    _logger.LogDebug("No images were updated.");
+                }
+            }
+            finally
+            {
+                _imageFetcherLock.Release();
+            }
+        }
+
+        private void OnLibraryManagerItemAddedOrUpdated(object sender, ItemChangeEventArgs itemChangeEventArgs)
+        {
+            if (!_queuedItems.ContainsKey(itemChangeEventArgs.Item.Id) && itemChangeEventArgs.Item.ImageInfos.Length > 0)
+            {
+                _queuedItems.AddOrUpdate(
+                    itemChangeEventArgs.Item.Id,
+                    (itemChangeEventArgs.Item, itemChangeEventArgs.UpdateReason),
+                    (key, existingValue) => existingValue);
+            }
+        }
+
+        private void OnProviderManagerRefreshCompleted(object sender, GenericEventArgs<BaseItem> e)
+        {
+            if (!_queuedItems.ContainsKey(e.Argument.Id) && e.Argument.ImageInfos.Length > 0)
+            {
+                _queuedItems.AddOrUpdate(
+                    e.Argument.Id,
+                    (e.Argument, ItemUpdateType.None),
+                    (key, existingValue) => existingValue);
+            }
+
+            // The RefreshCompleted event is a bit awkward in that it seems to _only_ be fired on
+            // the item that was refreshed regardless of children refreshes. So we take it as a signal
+            // that the refresh is entirely completed.
+            Run(null, CancellationToken.None).GetAwaiter().GetResult();
+        }
+    }
+}

+ 45 - 16
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -858,7 +858,21 @@ namespace Emby.Server.Implementations.Library
         /// <returns>Task{Person}.</returns>
         /// <returns>Task{Person}.</returns>
         public Person GetPerson(string name)
         public Person GetPerson(string name)
         {
         {
-            return CreateItemByName<Person>(Person.GetPath, name, new DtoOptions(true));
+            var path = Person.GetPath(name);
+            var id = GetItemByNameId<Person>(path);
+            if (!(GetItemById(id) is Person item))
+            {
+                item = new Person
+                {
+                    Name = name,
+                    Id = id,
+                    DateCreated = DateTime.UtcNow,
+                    DateModified = DateTime.UtcNow,
+                    Path = path
+                };
+            }
+
+            return item;
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -1503,7 +1517,7 @@ namespace Emby.Server.Implementations.Library
         {
         {
             if (query.AncestorIds.Length == 0 &&
             if (query.AncestorIds.Length == 0 &&
                 query.ParentId.Equals(Guid.Empty) &&
                 query.ParentId.Equals(Guid.Empty) &&
-                query.ChannelIds.Length == 0 &&
+                query.ChannelIds.Count == 0 &&
                 query.TopParentIds.Length == 0 &&
                 query.TopParentIds.Length == 0 &&
                 string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
                 string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
                 string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&
                 string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&
@@ -1941,19 +1955,9 @@ namespace Emby.Server.Implementations.Library
         }
         }
 
 
         /// <inheritdoc />
         /// <inheritdoc />
-        public async Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
+        public Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
         {
         {
-            foreach (var item in items)
-            {
-                if (item.IsFileProtocol)
-                {
-                    ProviderManager.SaveMetadata(item, updateReason);
-                }
-
-                item.DateLastSaved = DateTime.UtcNow;
-
-                await UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false);
-            }
+            RunMetadataSavers(items, updateReason);
 
 
             _itemRepository.SaveItems(items, cancellationToken);
             _itemRepository.SaveItems(items, cancellationToken);
 
 
@@ -1984,12 +1988,27 @@ namespace Emby.Server.Implementations.Library
                     }
                     }
                 }
                 }
             }
             }
+
+            return Task.CompletedTask;
         }
         }
 
 
         /// <inheritdoc />
         /// <inheritdoc />
         public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
         public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
             => UpdateItemsAsync(new[] { item }, parent, updateReason, cancellationToken);
             => UpdateItemsAsync(new[] { item }, parent, updateReason, cancellationToken);
 
 
+        public void RunMetadataSavers(IReadOnlyList<BaseItem> items, ItemUpdateType updateReason)
+        {
+            foreach (var item in items)
+            {
+                if (item.IsFileProtocol)
+                {
+                    ProviderManager.SaveMetadata(item, updateReason);
+                }
+
+                item.DateLastSaved = DateTime.UtcNow;
+            }
+        }
+
         /// <summary>
         /// <summary>
         /// Reports the item removed.
         /// Reports the item removed.
         /// </summary>
         /// </summary>
@@ -2443,9 +2462,19 @@ namespace Emby.Server.Implementations.Library
 
 
         public BaseItem GetParentItem(string parentId, Guid? userId)
         public BaseItem GetParentItem(string parentId, Guid? userId)
         {
         {
-            if (!string.IsNullOrEmpty(parentId))
+            if (string.IsNullOrEmpty(parentId))
+            {
+                return GetParentItem((Guid?)null, userId);
+            }
+
+            return GetParentItem(new Guid(parentId), userId);
+        }
+
+        public BaseItem GetParentItem(Guid? parentId, Guid? userId)
+        {
+            if (parentId.HasValue)
             {
             {
-                return GetItemById(new Guid(parentId));
+                return GetItemById(parentId.Value);
             }
             }
 
 
             if (userId.HasValue && userId != Guid.Empty)
             if (userId.HasValue && userId != Guid.Empty)

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

@@ -156,8 +156,8 @@ namespace Emby.Server.Implementations.Library
                 ExcludeItemTypes = excludeItemTypes.ToArray(),
                 ExcludeItemTypes = excludeItemTypes.ToArray(),
                 IncludeItemTypes = includeItemTypes.ToArray(),
                 IncludeItemTypes = includeItemTypes.ToArray(),
                 Limit = query.Limit,
                 Limit = query.Limit,
-                IncludeItemsByName = string.IsNullOrEmpty(query.ParentId),
-                ParentId = string.IsNullOrEmpty(query.ParentId) ? Guid.Empty : new Guid(query.ParentId),
+                IncludeItemsByName = !query.ParentId.HasValue,
+                ParentId = query.ParentId ?? Guid.Empty,
                 OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
                 OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
                 Recursive = true,
                 Recursive = true,
 
 

+ 33 - 56
Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs

@@ -4,7 +4,6 @@ using System;
 using System.Collections.Concurrent;
 using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Globalization;
-using System.IO;
 using System.Linq;
 using System.Linq;
 using System.Net;
 using System.Net;
 using System.Net.Http;
 using System.Net.Http;
@@ -19,7 +18,6 @@ using MediaBrowser.Model.Cryptography;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.LiveTv;
 using MediaBrowser.Model.LiveTv;
-using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Serialization;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 
 
@@ -36,6 +34,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings
         private readonly IApplicationHost _appHost;
         private readonly IApplicationHost _appHost;
         private readonly ICryptoProvider _cryptoProvider;
         private readonly ICryptoProvider _cryptoProvider;
 
 
+        private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>();
+        private DateTime _lastErrorResponse;
+
         public SchedulesDirect(
         public SchedulesDirect(
             ILogger<SchedulesDirect> logger,
             ILogger<SchedulesDirect> logger,
             IJsonSerializer jsonSerializer,
             IJsonSerializer jsonSerializer,
@@ -50,8 +51,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             _cryptoProvider = cryptoProvider;
             _cryptoProvider = cryptoProvider;
         }
         }
 
 
-        private string UserAgent => _appHost.ApplicationUserAgent;
-
         /// <inheritdoc />
         /// <inheritdoc />
         public string Name => "Schedules Direct";
         public string Name => "Schedules Direct";
 
 
@@ -307,7 +306,8 @@ namespace Emby.Server.Implementations.LiveTv.Listings
 
 
             if (details.contentRating != null && details.contentRating.Count > 0)
             if (details.contentRating != null && details.contentRating.Count > 0)
             {
             {
-                info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-").Replace("--", "-");
+                info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-", StringComparison.Ordinal)
+                    .Replace("--", "-", StringComparison.Ordinal);
 
 
                 var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" };
                 var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" };
                 if (invalid.Contains(info.OfficialRating, StringComparer.OrdinalIgnoreCase))
                 if (invalid.Contains(info.OfficialRating, StringComparer.OrdinalIgnoreCase))
@@ -450,7 +450,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
 
 
         private async Task<List<ScheduleDirect.ShowImages>> GetImageForPrograms(
         private async Task<List<ScheduleDirect.ShowImages>> GetImageForPrograms(
             ListingsProviderInfo info,
             ListingsProviderInfo info,
-            List<string> programIds,
+            IReadOnlyList<string> programIds,
             CancellationToken cancellationToken)
             CancellationToken cancellationToken)
         {
         {
             if (programIds.Count == 0)
             if (programIds.Count == 0)
@@ -458,23 +458,21 @@ namespace Emby.Server.Implementations.LiveTv.Listings
                 return new List<ScheduleDirect.ShowImages>();
                 return new List<ScheduleDirect.ShowImages>();
             }
             }
 
 
-            var imageIdString = "[";
-
-            foreach (var i in programIds)
+            StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13));
+            foreach (ReadOnlySpan<char> i in programIds)
             {
             {
-                var imageId = i.Substring(0, 10);
-
-                if (!imageIdString.Contains(imageId, StringComparison.Ordinal))
-                {
-                    imageIdString += "\"" + imageId + "\",";
-                }
+                str.Append('"')
+                    .Append(i.Slice(0, 10))
+                    .Append("\",");
             }
             }
 
 
-            imageIdString = imageIdString.TrimEnd(',') + "]";
+            // Remove last ,
+            str.Length--;
+            str.Append(']');
 
 
             using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs")
             using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs")
             {
             {
-                Content = new StringContent(imageIdString, Encoding.UTF8, MediaTypeNames.Application.Json)
+                Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json)
             };
             };
 
 
             try
             try
@@ -539,9 +537,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             return lineups;
             return lineups;
         }
         }
 
 
-        private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>();
-        private DateTime _lastErrorResponse;
-
         private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken)
         private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken)
         {
         {
             var username = info.Username;
             var username = info.Username;
@@ -564,8 +559,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
                 return null;
                 return null;
             }
             }
 
 
-            NameValuePair savedToken;
-            if (!_tokens.TryGetValue(username, out savedToken))
+            if (!_tokens.TryGetValue(username, out NameValuePair savedToken))
             {
             {
                 savedToken = new NameValuePair();
                 savedToken = new NameValuePair();
                 _tokens.TryAdd(username, savedToken);
                 _tokens.TryAdd(username, savedToken);
@@ -647,13 +641,15 @@ namespace Emby.Server.Implementations.LiveTv.Listings
         {
         {
             using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
             using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
             var hashedPasswordBytes = _cryptoProvider.ComputeHash("SHA1", Encoding.ASCII.GetBytes(password), Array.Empty<byte>());
             var hashedPasswordBytes = _cryptoProvider.ComputeHash("SHA1", Encoding.ASCII.GetBytes(password), Array.Empty<byte>());
-            string hashedPassword = Hex.Encode(hashedPasswordBytes);
+            // TODO: remove ToLower when Convert.ToHexString supports lowercase
+            // Schedules Direct requires the hex to be lowercase
+            string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant();
             options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
             options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
 
 
             using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
             using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Token>(stream).ConfigureAwait(false);
             var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Token>(stream).ConfigureAwait(false);
-            if (root.message == "OK")
+            if (string.Equals(root.message, "OK", StringComparison.Ordinal))
             {
             {
                 _logger.LogInformation("Authenticated with Schedules Direct token: " + root.token);
                 _logger.LogInformation("Authenticated with Schedules Direct token: " + root.token);
                 return root.token;
                 return root.token;
@@ -777,24 +773,28 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
             using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
             options.Headers.TryAddWithoutValidation("token", token);
             options.Headers.TryAddWithoutValidation("token", token);
 
 
-            var list = new List<ChannelInfo>();
-
             using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false);
             using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false);
             await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Channel>(stream).ConfigureAwait(false);
             var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Channel>(stream).ConfigureAwait(false);
             _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.map.Count);
             _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.map.Count);
             _logger.LogInformation("Mapping Stations to Channel");
             _logger.LogInformation("Mapping Stations to Channel");
 
 
-            var allStations = root.stations ?? Enumerable.Empty<ScheduleDirect.Station>();
+            var allStations = root.stations ?? new List<ScheduleDirect.Station>();
 
 
-            foreach (ScheduleDirect.Map map in root.map)
+            var map = root.map;
+            int len = map.Count;
+            var array = new List<ChannelInfo>(len);
+            for (int i = 0; i < len; i++)
             {
             {
-                var channelNumber = GetChannelNumber(map);
+                var channelNumber = GetChannelNumber(map[i]);
 
 
-                var station = allStations.FirstOrDefault(item => string.Equals(item.stationID, map.stationID, StringComparison.OrdinalIgnoreCase));
+                var station = allStations.Find(item => string.Equals(item.stationID, map[i].stationID, StringComparison.OrdinalIgnoreCase));
                 if (station == null)
                 if (station == null)
                 {
                 {
-                    station = new ScheduleDirect.Station { stationID = map.stationID };
+                    station = new ScheduleDirect.Station
+                    {
+                        stationID = map[i].stationID
+                    };
                 }
                 }
 
 
                 var channelInfo = new ChannelInfo
                 var channelInfo = new ChannelInfo
@@ -810,32 +810,10 @@ namespace Emby.Server.Implementations.LiveTv.Listings
                     channelInfo.ImageUrl = station.logo.URL;
                     channelInfo.ImageUrl = station.logo.URL;
                 }
                 }
 
 
-                list.Add(channelInfo);
-            }
-
-            return list;
-        }
-
-        private ScheduleDirect.Station GetStation(List<ScheduleDirect.Station> allStations, string channelNumber, string channelName)
-        {
-            if (!string.IsNullOrWhiteSpace(channelName))
-            {
-                channelName = NormalizeName(channelName);
-
-                var result = allStations.FirstOrDefault(i => string.Equals(NormalizeName(i.callsign ?? string.Empty), channelName, StringComparison.OrdinalIgnoreCase));
-
-                if (result != null)
-                {
-                    return result;
-                }
-            }
-
-            if (!string.IsNullOrWhiteSpace(channelNumber))
-            {
-                return allStations.FirstOrDefault(i => string.Equals(NormalizeName(i.stationID ?? string.Empty), channelNumber, StringComparison.OrdinalIgnoreCase));
+                array[i] = channelInfo;
             }
             }
 
 
-            return null;
+            return array;
         }
         }
 
 
         private static string NormalizeName(string value)
         private static string NormalizeName(string value)
@@ -1044,7 +1022,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings
                 }
                 }
             }
             }
 
 
-            //
             public class Title
             public class Title
             {
             {
                 public string title120 { get; set; }
                 public string title120 { get; set; }

+ 1 - 2
Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs

@@ -76,7 +76,6 @@ namespace Emby.Server.Implementations.LiveTv
             }
             }
 
 
             var list = sources.ToList();
             var list = sources.ToList();
-            var serverUrl = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
 
 
             foreach (var source in list)
             foreach (var source in list)
             {
             {
@@ -103,7 +102,7 @@ namespace Emby.Server.Implementations.LiveTv
                 // Dummy this up so that direct play checks can still run
                 // Dummy this up so that direct play checks can still run
                 if (string.IsNullOrEmpty(source.Path) && source.Protocol == MediaProtocol.Http)
                 if (string.IsNullOrEmpty(source.Path) && source.Protocol == MediaProtocol.Http)
                 {
                 {
-                    source.Path = serverUrl;
+                    source.Path = _appHost.GetSmartApiUrl(string.Empty);
                 }
                 }
             }
             }
 
 

+ 1 - 2
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs

@@ -237,8 +237,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
 
 
                 if (!inside)
                 if (!inside)
                 {
                 {
-                    buffer[bufferIndex] = let;
-                    bufferIndex++;
+                    buffer[bufferIndex++] = let;
                 }
                 }
             }
             }
 
 

+ 26 - 25
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs

@@ -111,11 +111,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
 
 
         public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken)
         public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken)
         {
         {
-            using (var client = new TcpClient(new IPEndPoint(remoteIp, HdHomeRunPort)))
-            using (var stream = client.GetStream())
-            {
-                return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false);
-            }
+            using var client = new TcpClient();
+            client.Connect(remoteIp, HdHomeRunPort);
+
+            using var stream = client.GetStream();
+            return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false);
         }
         }
 
 
         private static async Task<bool> CheckTunerAvailability(NetworkStream stream, int tuner, CancellationToken cancellationToken)
         private static async Task<bool> CheckTunerAvailability(NetworkStream stream, int tuner, CancellationToken cancellationToken)
@@ -142,7 +142,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
         {
         {
             _remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort);
             _remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort);
 
 
-            _tcpClient = new TcpClient(_remoteEndPoint);
+            _tcpClient = new TcpClient();
+            _tcpClient.Connect(_remoteEndPoint);
 
 
             if (!_lockkey.HasValue)
             if (!_lockkey.HasValue)
             {
             {
@@ -221,30 +222,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 return;
                 return;
             }
             }
 
 
-            using (var tcpClient = new TcpClient(_remoteEndPoint))
-            using (var stream = tcpClient.GetStream())
+            using var tcpClient = new TcpClient();
+            tcpClient.Connect(_remoteEndPoint);
+
+            using var stream = tcpClient.GetStream();
+            var commandList = commands.GetCommands();
+            byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
+            try
             {
             {
-                var commandList = commands.GetCommands();
-                byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
-                try
+                foreach (var command in commandList)
                 {
                 {
-                    foreach (var command in commandList)
-                    {
-                        var channelMsg = CreateSetMessage(_activeTuner, command.Item1, command.Item2, _lockkey);
-                        await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false);
-                        int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
+                    var channelMsg = CreateSetMessage(_activeTuner, command.Item1, command.Item2, _lockkey);
+                    await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false);
+                    int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
 
 
-                        // parse response to make sure it worked
-                        if (!ParseReturnMessage(buffer, receivedBytes, out _))
-                        {
-                            return;
-                        }
+                    // parse response to make sure it worked
+                    if (!ParseReturnMessage(buffer, receivedBytes, out _))
+                    {
+                        return;
                     }
                     }
                 }
                 }
-                finally
-                {
-                    ArrayPool<byte>.Shared.Return(buffer);
-                }
+            }
+            finally
+            {
+                ArrayPool<byte>.Shared.Return(buffer);
             }
             }
         }
         }
 
 

+ 50 - 26
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs

@@ -3,7 +3,9 @@
 using System;
 using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.IO;
 using System.IO;
+using System.Linq;
 using System.Net;
 using System.Net;
+using System.Net.NetworkInformation;
 using System.Net.Sockets;
 using System.Net.Sockets;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
@@ -50,6 +52,26 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             EnableStreamSharing = true;
             EnableStreamSharing = true;
         }
         }
 
 
+        /// <summary>
+        /// Returns an unused UDP port number in the range specified.
+        /// Temporarily placed here until future network PR merged.
+        /// </summary>
+        /// <param name="range">Upper and Lower boundary of ports to select.</param>
+        /// <returns>System.Int32.</returns>
+        private static int GetUdpPortFromRange((int Min, int Max) range)
+        {
+            var properties = IPGlobalProperties.GetIPGlobalProperties();
+
+            // Get active udp listeners.
+            var udpListenerPorts = properties.GetActiveUdpListeners()
+                        .Where(n => n.Port >= range.Min && n.Port <= range.Max)
+                        .Select(n => n.Port);
+
+            return Enumerable
+                .Range(range.Min, range.Max)
+                .FirstOrDefault(i => !udpListenerPorts.Contains(i));
+        }
+
         public override async Task Open(CancellationToken openCancellationToken)
         public override async Task Open(CancellationToken openCancellationToken)
         {
         {
             LiveStreamCancellationTokenSource.Token.ThrowIfCancellationRequested();
             LiveStreamCancellationTokenSource.Token.ThrowIfCancellationRequested();
@@ -57,7 +79,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             var mediaSource = OriginalMediaSource;
             var mediaSource = OriginalMediaSource;
 
 
             var uri = new Uri(mediaSource.Path);
             var uri = new Uri(mediaSource.Path);
-            var localPort = _networkManager.GetRandomUnusedUdpPort();
+            // Temporary code to reduce PR size. This will be updated by a future network pr.
+            var localPort = GetUdpPortFromRange((49152, 65535));
 
 
             Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath));
             Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath));
 
 
@@ -70,7 +93,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 try
                 try
                 {
                 {
                     await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort).ConfigureAwait(false);
                     await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort).ConfigureAwait(false);
-                    localAddress = ((IPEndPoint)tcpClient.Client.RemoteEndPoint).Address;
+                    localAddress = ((IPEndPoint)tcpClient.Client.LocalEndPoint).Address;
                     tcpClient.Close();
                     tcpClient.Close();
                 }
                 }
                 catch (Exception ex)
                 catch (Exception ex)
@@ -80,6 +103,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 }
                 }
             }
             }
 
 
+            if (localAddress.IsIPv4MappedToIPv6) {
+                localAddress = localAddress.MapToIPv4();
+            }
+
             var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork);
             var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork);
             var hdHomerunManager = new HdHomerunManager();
             var hdHomerunManager = new HdHomerunManager();
 
 
@@ -110,12 +137,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
 
 
             var taskCompletionSource = new TaskCompletionSource<bool>();
             var taskCompletionSource = new TaskCompletionSource<bool>();
 
 
-            await StartStreaming(
+            _ = StartStreaming(
                 udpClient,
                 udpClient,
                 hdHomerunManager,
                 hdHomerunManager,
                 remoteAddress,
                 remoteAddress,
                 taskCompletionSource,
                 taskCompletionSource,
-                LiveStreamCancellationTokenSource.Token).ConfigureAwait(false);
+                LiveStreamCancellationTokenSource.Token);
 
 
             // OpenedMediaSource.Protocol = MediaProtocol.File;
             // OpenedMediaSource.Protocol = MediaProtocol.File;
             // OpenedMediaSource.Path = tempFile;
             // OpenedMediaSource.Path = tempFile;
@@ -136,33 +163,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             return TempFilePath;
             return TempFilePath;
         }
         }
 
 
-        private Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
+        private async Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
         {
         {
-            return Task.Run(async () =>
+            using (udpClient)
+            using (hdHomerunManager)
             {
             {
-                using (udpClient)
-                using (hdHomerunManager)
+                try
                 {
                 {
-                    try
-                    {
-                        await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false);
-                    }
-                    catch (OperationCanceledException ex)
-                    {
-                        Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress);
-                        openTaskCompletionSource.TrySetException(ex);
-                    }
-                    catch (Exception ex)
-                    {
-                        Logger.LogError(ex, "Error opening live stream:");
-                        openTaskCompletionSource.TrySetException(ex);
-                    }
-
-                    EnableStreamSharing = false;
+                    await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false);
+                }
+                catch (OperationCanceledException ex)
+                {
+                    Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress);
+                    openTaskCompletionSource.TrySetException(ex);
+                }
+                catch (Exception ex)
+                {
+                    Logger.LogError(ex, "Error opening live stream:");
+                    openTaskCompletionSource.TrySetException(ex);
                 }
                 }
 
 
-                await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false);
-            });
+                EnableStreamSharing = false;
+            }
+
+            await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false);
         }
         }
 
 
         private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
         private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)

+ 1 - 1
Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs

@@ -95,7 +95,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
                     extInf = line.Substring(ExtInfPrefix.Length).Trim();
                     extInf = line.Substring(ExtInfPrefix.Length).Trim();
                     _logger.LogInformation("Found m3u channel: {0}", extInf);
                     _logger.LogInformation("Found m3u channel: {0}", extInf);
                 }
                 }
-                else if (!string.IsNullOrWhiteSpace(extInf) && !line.StartsWith("#", StringComparison.OrdinalIgnoreCase))
+                else if (!string.IsNullOrWhiteSpace(extInf) && !line.StartsWith('#'))
                 {
                 {
                     var channel = GetChannelnfo(extInf, tunerHostId, line);
                     var channel = GetChannelnfo(extInf, tunerHostId, line);
                     if (string.IsNullOrWhiteSpace(channel.Id))
                     if (string.IsNullOrWhiteSpace(channel.Id))

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

@@ -63,7 +63,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
             var extension = "ts";
             var extension = "ts";
             var requiresRemux = false;
             var requiresRemux = false;
 
 
-            var contentType = response.Content.Headers.ContentType.ToString();
+            var contentType = response.Content.Headers.ContentType?.ToString() ?? string.Empty;
             if (contentType.IndexOf("matroska", StringComparison.OrdinalIgnoreCase) != -1)
             if (contentType.IndexOf("matroska", StringComparison.OrdinalIgnoreCase) != -1)
             {
             {
                 requiresRemux = true;
                 requiresRemux = true;

+ 6 - 1
Emby.Server.Implementations/Localization/Core/da.json

@@ -113,5 +113,10 @@
     "TaskCleanTranscodeDescription": "Fjern transcode filer som er mere end en dag gammel.",
     "TaskCleanTranscodeDescription": "Fjern transcode filer som er mere end en dag gammel.",
     "TaskCleanTranscode": "Rengør Transcode Mappen",
     "TaskCleanTranscode": "Rengør Transcode Mappen",
     "TaskRefreshPeople": "Genopfrisk Personer",
     "TaskRefreshPeople": "Genopfrisk Personer",
-    "TaskRefreshPeopleDescription": "Opdatere metadata for skuespillere og instruktører i dit bibliotek."
+    "TaskRefreshPeopleDescription": "Opdatere metadata for skuespillere og instruktører i dit bibliotek.",
+    "TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigureret alder.",
+    "TaskCleanActivityLog": "Ryd Aktivitetslog",
+    "Undefined": "Udefineret",
+    "Forced": "Tvunget",
+    "Default": "Standard"
 }
 }

+ 6 - 1
Emby.Server.Implementations/Localization/Core/el.json

@@ -113,5 +113,10 @@
     "TaskCleanTranscode": "Καθαρισμός Kαταλόγου Διακωδικοποιητή",
     "TaskCleanTranscode": "Καθαρισμός Kαταλόγου Διακωδικοποιητή",
     "TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τις προσθήκες που έχουν ρυθμιστεί για αυτόματη ενημέρωση.",
     "TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τις προσθήκες που έχουν ρυθμιστεί για αυτόματη ενημέρωση.",
     "TaskUpdatePlugins": "Ενημέρωση Προσθηκών",
     "TaskUpdatePlugins": "Ενημέρωση Προσθηκών",
-    "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας."
+    "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας.",
+    "TaskCleanActivityLogDescription": "Διαγράφει καταχωρήσεις απο το αρχείο καταγραφής παλαιότερες από την επιλεγμένη ηλικία.",
+    "TaskCleanActivityLog": "Καθαρό Αρχείο Καταγραφής Δραστηριοτήτων",
+    "Undefined": "Απροσδιόριστο",
+    "Forced": "Εξαναγκασμένο",
+    "Default": "Προεπιλογή"
 }
 }

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

@@ -113,5 +113,9 @@
     "TasksChannelsCategory": "Canales de Internet",
     "TasksChannelsCategory": "Canales de Internet",
     "TasksApplicationCategory": "Aplicación",
     "TasksApplicationCategory": "Aplicación",
     "TasksLibraryCategory": "Biblioteca",
     "TasksLibraryCategory": "Biblioteca",
-    "TasksMaintenanceCategory": "Mantenimiento"
+    "TasksMaintenanceCategory": "Mantenimiento",
+    "TaskCleanActivityLogDescription": "Elimina entradas del registro de actividad que sean más antiguas al periodo establecido.",
+    "TaskCleanActivityLog": "Limpiar registro de actividades",
+    "Undefined": "Sin definir",
+    "Forced": "Forzado"
 }
 }

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

@@ -70,7 +70,7 @@
     "ScheduledTaskFailedWithName": "{0} falló",
     "ScheduledTaskFailedWithName": "{0} falló",
     "ScheduledTaskStartedWithName": "{0} iniciada",
     "ScheduledTaskStartedWithName": "{0} iniciada",
     "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado",
     "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado",
-    "Shows": "Mostrar",
+    "Shows": "Series de Televisión",
     "Songs": "Canciones",
     "Songs": "Canciones",
     "StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.",
     "StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.",
     "SubtitleDownloadFailureForItem": "Error al descargar subtítulos para {0}",
     "SubtitleDownloadFailureForItem": "Error al descargar subtítulos para {0}",

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

@@ -112,5 +112,7 @@
     "TaskCleanCache": "Tyhjennä välimuisti-hakemisto",
     "TaskCleanCache": "Tyhjennä välimuisti-hakemisto",
     "TasksChannelsCategory": "Internet kanavat",
     "TasksChannelsCategory": "Internet kanavat",
     "TasksApplicationCategory": "Sovellus",
     "TasksApplicationCategory": "Sovellus",
-    "TasksLibraryCategory": "Kirjasto"
+    "TasksLibraryCategory": "Kirjasto",
+    "Forced": "Pakotettu",
+    "Default": "Oletus"
 }
 }

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

@@ -113,5 +113,6 @@
     "TaskCleanCache": "Nettoyer le répertoire des fichiers temporaires",
     "TaskCleanCache": "Nettoyer le répertoire des fichiers temporaires",
     "TasksApplicationCategory": "Application",
     "TasksApplicationCategory": "Application",
     "TaskCleanCacheDescription": "Supprime les fichiers temporaires qui ne sont plus nécessaire pour le système.",
     "TaskCleanCacheDescription": "Supprime les fichiers temporaires qui ne sont plus nécessaire pour le système.",
-    "TasksChannelsCategory": "Canaux Internet"
+    "TasksChannelsCategory": "Canaux Internet",
+    "Default": "Par défaut"
 }
 }

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

@@ -93,8 +93,8 @@
     "ValueSpecialEpisodeName": "Spécial - {0}",
     "ValueSpecialEpisodeName": "Spécial - {0}",
     "VersionNumber": "Version {0}",
     "VersionNumber": "Version {0}",
     "TasksChannelsCategory": "Chaines en ligne",
     "TasksChannelsCategory": "Chaines en ligne",
-    "TaskDownloadMissingSubtitlesDescription": "Cherche les sous-titres manquant sur internet en se basant sur la configuration des métadonnées.",
-    "TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquant",
+    "TaskDownloadMissingSubtitlesDescription": "Recherche les sous-titres manquants sur internet en se basant sur la configuration des métadonnées.",
+    "TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquants",
     "TaskRefreshChannelsDescription": "Rafraîchit les informations des chaines en ligne.",
     "TaskRefreshChannelsDescription": "Rafraîchit les informations des chaines en ligne.",
     "TaskRefreshChannels": "Rafraîchir les chaines",
     "TaskRefreshChannels": "Rafraîchir les chaines",
     "TaskCleanTranscodeDescription": "Supprime les fichiers transcodés de plus d'un jour.",
     "TaskCleanTranscodeDescription": "Supprime les fichiers transcodés de plus d'un jour.",

+ 4 - 1
Emby.Server.Implementations/Localization/Core/hu.json

@@ -115,5 +115,8 @@
     "TaskRefreshChannels": "Csatornák frissítése",
     "TaskRefreshChannels": "Csatornák frissítése",
     "TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat.",
     "TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat.",
     "TaskCleanActivityLogDescription": "A beállítottnál korábbi bejegyzések törlése a tevékenységnaplóból.",
     "TaskCleanActivityLogDescription": "A beállítottnál korábbi bejegyzések törlése a tevékenységnaplóból.",
-    "TaskCleanActivityLog": "Tevékenységnapló törlése"
+    "TaskCleanActivityLog": "Tevékenységnapló törlése",
+    "Undefined": "Meghatározatlan",
+    "Forced": "Kényszerített",
+    "Default": "Alapértelmezett"
 }
 }

+ 6 - 1
Emby.Server.Implementations/Localization/Core/id.json

@@ -112,5 +112,10 @@
     "TaskRefreshPeople": "Muat ulang Orang",
     "TaskRefreshPeople": "Muat ulang Orang",
     "TaskCleanLogsDescription": "Menghapus file log yang lebih dari {0} hari.",
     "TaskCleanLogsDescription": "Menghapus file log yang lebih dari {0} hari.",
     "TaskCleanLogs": "Bersihkan Log Direktori",
     "TaskCleanLogs": "Bersihkan Log Direktori",
-    "TaskRefreshLibrary": "Pindai Pustaka Media"
+    "TaskRefreshLibrary": "Pindai Pustaka Media",
+    "TaskCleanActivityLogDescription": "Menghapus log aktivitas yang lebih tua dari umur yang dikonfigurasi.",
+    "TaskCleanActivityLog": "Bersihkan Log Aktivitas",
+    "Undefined": "Tidak terdefinisi",
+    "Forced": "Dipaksa",
+    "Default": "Bawaan"
 }
 }

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

@@ -115,5 +115,8 @@
     "TasksLibraryCategory": "Libreria",
     "TasksLibraryCategory": "Libreria",
     "TasksMaintenanceCategory": "Manutenzione",
     "TasksMaintenanceCategory": "Manutenzione",
     "TaskCleanActivityLog": "Attività di Registro Completate",
     "TaskCleanActivityLog": "Attività di Registro Completate",
-    "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata."
+    "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata.",
+    "Undefined": "Non Definito",
+    "Forced": "Forzato",
+    "Default": "Predefinito"
 }
 }

+ 5 - 2
Emby.Server.Implementations/Localization/Core/nl.json

@@ -87,7 +87,7 @@
     "UserOnlineFromDevice": "{0} heeft verbinding met {1}",
     "UserOnlineFromDevice": "{0} heeft verbinding met {1}",
     "UserPasswordChangedWithName": "Wachtwoord voor {0} is gewijzigd",
     "UserPasswordChangedWithName": "Wachtwoord voor {0} is gewijzigd",
     "UserPolicyUpdatedWithName": "Gebruikersbeleid gewijzigd voor {0}",
     "UserPolicyUpdatedWithName": "Gebruikersbeleid gewijzigd voor {0}",
-    "UserStartedPlayingItemWithValues": "{0} heeft afspelen van {1} gestart op {2}",
+    "UserStartedPlayingItemWithValues": "{0} speelt {1} af op {2}",
     "UserStoppedPlayingItemWithValues": "{0} heeft afspelen van {1} gestopt op {2}",
     "UserStoppedPlayingItemWithValues": "{0} heeft afspelen van {1} gestopt op {2}",
     "ValueHasBeenAddedToLibrary": "{0} is toegevoegd aan je mediabibliotheek",
     "ValueHasBeenAddedToLibrary": "{0} is toegevoegd aan je mediabibliotheek",
     "ValueSpecialEpisodeName": "Speciaal - {0}",
     "ValueSpecialEpisodeName": "Speciaal - {0}",
@@ -115,5 +115,8 @@
     "TasksLibraryCategory": "Bibliotheek",
     "TasksLibraryCategory": "Bibliotheek",
     "TasksMaintenanceCategory": "Onderhoud",
     "TasksMaintenanceCategory": "Onderhoud",
     "TaskCleanActivityLogDescription": "Verwijder activiteiten logs ouder dan de ingestelde tijd.",
     "TaskCleanActivityLogDescription": "Verwijder activiteiten logs ouder dan de ingestelde tijd.",
-    "TaskCleanActivityLog": "Leeg activiteiten logboek"
+    "TaskCleanActivityLog": "Leeg activiteiten logboek",
+    "Undefined": "Niet gedefinieerd",
+    "Forced": "Geforceerd",
+    "Default": "Standaard"
 }
 }

+ 6 - 1
Emby.Server.Implementations/Localization/Core/pt-PT.json

@@ -113,5 +113,10 @@
     "TasksChannelsCategory": "Canais da Internet",
     "TasksChannelsCategory": "Canais da Internet",
     "TasksApplicationCategory": "Aplicação",
     "TasksApplicationCategory": "Aplicação",
     "TasksLibraryCategory": "Biblioteca",
     "TasksLibraryCategory": "Biblioteca",
-    "TasksMaintenanceCategory": "Manutenção"
+    "TasksMaintenanceCategory": "Manutenção",
+    "TaskCleanActivityLogDescription": "Apaga as entradas do registo de atividade anteriores à data configurada.",
+    "TaskCleanActivityLog": "Limpar registo de atividade",
+    "Undefined": "Indefinido",
+    "Forced": "Forçado",
+    "Default": "Padrão"
 }
 }

+ 6 - 1
Emby.Server.Implementations/Localization/Core/pt.json

@@ -112,5 +112,10 @@
     "TaskUpdatePluginsDescription": "Download e instala as atualizações para plug-ins configurados para atualização automática.",
     "TaskUpdatePluginsDescription": "Download e instala as atualizações para plug-ins configurados para atualização automática.",
     "TaskRefreshPeopleDescription": "Atualiza os metadados para atores e diretores na tua biblioteca de media.",
     "TaskRefreshPeopleDescription": "Atualiza os metadados para atores e diretores na tua biblioteca de media.",
     "TaskRefreshPeople": "Atualizar pessoas",
     "TaskRefreshPeople": "Atualizar pessoas",
-    "TaskRefreshLibraryDescription": "Pesquisa a tua biblioteca de media por novos ficheiros e atualiza os metadados."
+    "TaskRefreshLibraryDescription": "Pesquisa a tua biblioteca de media por novos ficheiros e atualiza os metadados.",
+    "TaskCleanActivityLog": "Limpar registo de atividade",
+    "Undefined": "Indefinido",
+    "Forced": "Forçado",
+    "Default": "Predefinição",
+    "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado."
 }
 }

+ 4 - 1
Emby.Server.Implementations/Localization/Core/ro.json

@@ -114,5 +114,8 @@
     "TasksLibraryCategory": "Librărie",
     "TasksLibraryCategory": "Librărie",
     "TasksMaintenanceCategory": "Mentenanță",
     "TasksMaintenanceCategory": "Mentenanță",
     "TaskCleanActivityLogDescription": "Șterge intrările din jurnalul de activitate mai vechi de data configurată.",
     "TaskCleanActivityLogDescription": "Șterge intrările din jurnalul de activitate mai vechi de data configurată.",
-    "TaskCleanActivityLog": "Curăță Jurnalul de Activitate"
+    "TaskCleanActivityLog": "Curăță Jurnalul de Activitate",
+    "Undefined": "Nedefinit",
+    "Forced": "Forțat",
+    "Default": "Implicit"
 }
 }

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

@@ -115,5 +115,8 @@
     "TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.",
     "TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.",
     "TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе.",
     "TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе.",
     "TaskCleanActivityLogDescription": "Удаляет записи журнала активности старше установленного возраста.",
     "TaskCleanActivityLogDescription": "Удаляет записи журнала активности старше установленного возраста.",
-    "TaskCleanActivityLog": "Очистить журнал активности"
+    "TaskCleanActivityLog": "Очистить журнал активности",
+    "Undefined": "Не определено",
+    "Forced": "Форсир-ые",
+    "Default": "По умолчанию"
 }
 }

+ 19 - 14
Emby.Server.Implementations/Localization/Core/sk.json

@@ -2,7 +2,7 @@
     "Albums": "Albumy",
     "Albums": "Albumy",
     "AppDeviceValues": "Aplikácia: {0}, Zariadenie: {1}",
     "AppDeviceValues": "Aplikácia: {0}, Zariadenie: {1}",
     "Application": "Aplikácia",
     "Application": "Aplikácia",
-    "Artists": "Umelci",
+    "Artists": "Interpreti",
     "AuthenticationSucceededWithUserName": "{0} úspešne overený",
     "AuthenticationSucceededWithUserName": "{0} úspešne overený",
     "Books": "Knihy",
     "Books": "Knihy",
     "CameraImageUploadedFrom": "Z {0} bola nahraná nová fotografia",
     "CameraImageUploadedFrom": "Z {0} bola nahraná nová fotografia",
@@ -15,13 +15,13 @@
     "Favorites": "Obľúbené",
     "Favorites": "Obľúbené",
     "Folders": "Priečinky",
     "Folders": "Priečinky",
     "Genres": "Žánre",
     "Genres": "Žánre",
-    "HeaderAlbumArtists": "Umelci albumu",
+    "HeaderAlbumArtists": "Interpreti albumu",
     "HeaderContinueWatching": "Pokračovať v pozeraní",
     "HeaderContinueWatching": "Pokračovať v pozeraní",
     "HeaderFavoriteAlbums": "Obľúbené albumy",
     "HeaderFavoriteAlbums": "Obľúbené albumy",
-    "HeaderFavoriteArtists": "Obľúbení umelci",
+    "HeaderFavoriteArtists": "Obľúbení interpreti",
     "HeaderFavoriteEpisodes": "Obľúbené epizódy",
     "HeaderFavoriteEpisodes": "Obľúbené epizódy",
     "HeaderFavoriteShows": "Obľúbené seriály",
     "HeaderFavoriteShows": "Obľúbené seriály",
-    "HeaderFavoriteSongs": "Obľúbené piesne",
+    "HeaderFavoriteSongs": "Obľúbené skladby",
     "HeaderLiveTV": "Živá TV",
     "HeaderLiveTV": "Živá TV",
     "HeaderNextUp": "Nasleduje",
     "HeaderNextUp": "Nasleduje",
     "HeaderRecordingGroups": "Skupiny nahrávok",
     "HeaderRecordingGroups": "Skupiny nahrávok",
@@ -33,13 +33,13 @@
     "LabelRunningTimeValue": "Dĺžka: {0}",
     "LabelRunningTimeValue": "Dĺžka: {0}",
     "Latest": "Najnovšie",
     "Latest": "Najnovšie",
     "MessageApplicationUpdated": "Jellyfin Server bol aktualizovaný",
     "MessageApplicationUpdated": "Jellyfin Server bol aktualizovaný",
-    "MessageApplicationUpdatedTo": "Jellyfin Server bol aktualizový na verziu {0}",
+    "MessageApplicationUpdatedTo": "Jellyfin Server bol aktualizovaný na verziu {0}",
     "MessageNamedServerConfigurationUpdatedWithValue": "Sekcia {0} konfigurácie servera bola aktualizovaná",
     "MessageNamedServerConfigurationUpdatedWithValue": "Sekcia {0} konfigurácie servera bola aktualizovaná",
     "MessageServerConfigurationUpdated": "Konfigurácia servera bola aktualizovaná",
     "MessageServerConfigurationUpdated": "Konfigurácia servera bola aktualizovaná",
     "MixedContent": "Zmiešaný obsah",
     "MixedContent": "Zmiešaný obsah",
     "Movies": "Filmy",
     "Movies": "Filmy",
     "Music": "Hudba",
     "Music": "Hudba",
-    "MusicVideos": "Hudobné videá",
+    "MusicVideos": "Hudobné videoklipy",
     "NameInstallFailed": "Inštalácia {0} zlyhala",
     "NameInstallFailed": "Inštalácia {0} zlyhala",
     "NameSeasonNumber": "Séria {0}",
     "NameSeasonNumber": "Séria {0}",
     "NameSeasonUnknown": "Neznáma séria",
     "NameSeasonUnknown": "Neznáma séria",
@@ -71,7 +71,7 @@
     "ScheduledTaskStartedWithName": "{0} zahájených",
     "ScheduledTaskStartedWithName": "{0} zahájených",
     "ServerNameNeedsToBeRestarted": "{0} vyžaduje reštart",
     "ServerNameNeedsToBeRestarted": "{0} vyžaduje reštart",
     "Shows": "Seriály",
     "Shows": "Seriály",
-    "Songs": "Piesne",
+    "Songs": "Skladby",
     "StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.",
     "StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.",
     "SubtitleDownloadFailureForItem": "Sťahovanie titulkov pre {0} zlyhalo",
     "SubtitleDownloadFailureForItem": "Sťahovanie titulkov pre {0} zlyhalo",
     "SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo",
     "SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo",
@@ -89,29 +89,34 @@
     "UserPolicyUpdatedWithName": "Používateľské zásady pre {0} boli aktualizované",
     "UserPolicyUpdatedWithName": "Používateľské zásady pre {0} boli aktualizované",
     "UserStartedPlayingItemWithValues": "{0} spustil prehrávanie {1} na {2}",
     "UserStartedPlayingItemWithValues": "{0} spustil prehrávanie {1} na {2}",
     "UserStoppedPlayingItemWithValues": "{0} ukončil prehrávanie {1} na {2}",
     "UserStoppedPlayingItemWithValues": "{0} ukončil prehrávanie {1} na {2}",
-    "ValueHasBeenAddedToLibrary": "{0} bol pridané do vašej knižnice médií",
+    "ValueHasBeenAddedToLibrary": "{0} bol pridaný do vašej knižnice médií",
     "ValueSpecialEpisodeName": "Špeciál - {0}",
     "ValueSpecialEpisodeName": "Špeciál - {0}",
     "VersionNumber": "Verzia {0}",
     "VersionNumber": "Verzia {0}",
     "TaskDownloadMissingSubtitlesDescription": "Vyhľadá na internete chýbajúce titulky podľa toho, ako sú nakonfigurované metadáta.",
     "TaskDownloadMissingSubtitlesDescription": "Vyhľadá na internete chýbajúce titulky podľa toho, ako sú nakonfigurované metadáta.",
     "TaskDownloadMissingSubtitles": "Stiahnuť chýbajúce titulky",
     "TaskDownloadMissingSubtitles": "Stiahnuť chýbajúce titulky",
     "TaskRefreshChannelsDescription": "Obnoví informácie o internetových kanáloch.",
     "TaskRefreshChannelsDescription": "Obnoví informácie o internetových kanáloch.",
     "TaskRefreshChannels": "Obnoviť kanály",
     "TaskRefreshChannels": "Obnoviť kanály",
-    "TaskCleanTranscodeDescription": "Vymaže súbory transkódovania, ktoré sú staršie ako jeden deň.",
-    "TaskCleanTranscode": "Vyčistiť priečinok pre transkódovanie",
+    "TaskCleanTranscodeDescription": "Vymaže prekódované súbory, ktoré sú staršie ako jeden deň.",
+    "TaskCleanTranscode": "Vyčistiť priečinok pre prekódovanie",
     "TaskUpdatePluginsDescription": "Stiahne a nainštaluje aktualizácie pre zásuvné moduly, ktoré sú nastavené tak, aby sa aktualizovali automaticky.",
     "TaskUpdatePluginsDescription": "Stiahne a nainštaluje aktualizácie pre zásuvné moduly, ktoré sú nastavené tak, aby sa aktualizovali automaticky.",
     "TaskUpdatePlugins": "Aktualizovať zásuvné moduly",
     "TaskUpdatePlugins": "Aktualizovať zásuvné moduly",
     "TaskRefreshPeopleDescription": "Aktualizuje metadáta pre hercov a režisérov vo vašej mediálnej knižnici.",
     "TaskRefreshPeopleDescription": "Aktualizuje metadáta pre hercov a režisérov vo vašej mediálnej knižnici.",
     "TaskRefreshPeople": "Obnoviť osoby",
     "TaskRefreshPeople": "Obnoviť osoby",
-    "TaskCleanLogsDescription": "Vymaže log súbory, ktoré su staršie ako {0} deň/dni/dní.",
+    "TaskCleanLogsDescription": "Vymaže log súbory, ktoré sú staršie ako {0} deň/dni/dní.",
     "TaskCleanLogs": "Vyčistiť priečinok s logmi",
     "TaskCleanLogs": "Vyčistiť priečinok s logmi",
     "TaskRefreshLibraryDescription": "Hľadá vo vašej mediálnej knižnici nové súbory a obnovuje metadáta.",
     "TaskRefreshLibraryDescription": "Hľadá vo vašej mediálnej knižnici nové súbory a obnovuje metadáta.",
     "TaskRefreshLibrary": "Prehľadávať knižnicu medií",
     "TaskRefreshLibrary": "Prehľadávať knižnicu medií",
     "TaskRefreshChapterImagesDescription": "Vytvorí náhľady pre videá, ktoré majú kapitoly.",
     "TaskRefreshChapterImagesDescription": "Vytvorí náhľady pre videá, ktoré majú kapitoly.",
     "TaskRefreshChapterImages": "Extrahovať obrázky kapitol",
     "TaskRefreshChapterImages": "Extrahovať obrázky kapitol",
-    "TaskCleanCacheDescription": "Vymaže cache súbory, ktoré nie sú potrebné pre systém.",
-    "TaskCleanCache": "Vyčistiť Cache priečinok",
+    "TaskCleanCacheDescription": "Vymaže súbory vyrovnávacej pamäte, ktoré nie sú potrebné pre systém.",
+    "TaskCleanCache": "Vyčistiť priečinok vyrovnávacej pamäte",
     "TasksChannelsCategory": "Internetové kanály",
     "TasksChannelsCategory": "Internetové kanály",
     "TasksApplicationCategory": "Aplikácia",
     "TasksApplicationCategory": "Aplikácia",
     "TasksLibraryCategory": "Knižnica",
     "TasksLibraryCategory": "Knižnica",
-    "TasksMaintenanceCategory": "Údržba"
+    "TasksMaintenanceCategory": "Údržba",
+    "TaskCleanActivityLogDescription": "Vymaže záznamy aktivít v logu, ktoré sú staršie ako zadaná doba.",
+    "TaskCleanActivityLog": "Vyčistiť log aktivít",
+    "Undefined": "Nedefinované",
+    "Forced": "Vynútené",
+    "Default": "Predvolené"
 }
 }

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

@@ -21,7 +21,7 @@
     "Inherit": "மரபுரிமையாகப் பெறு",
     "Inherit": "மரபுரிமையாகப் பெறு",
     "HeaderRecordingGroups": "பதிவு குழுக்கள்",
     "HeaderRecordingGroups": "பதிவு குழுக்கள்",
     "Folders": "கோப்புறைகள்",
     "Folders": "கோப்புறைகள்",
-    "FailedLoginAttemptWithUserName": "{0} இலிருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது",
+    "FailedLoginAttemptWithUserName": "{0} இல் இருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது",
     "DeviceOnlineWithName": "{0} இணைக்கப்பட்டது",
     "DeviceOnlineWithName": "{0} இணைக்கப்பட்டது",
     "DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது",
     "DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது",
     "Collections": "தொகுப்புகள்",
     "Collections": "தொகுப்புகள்",
@@ -99,7 +99,7 @@
     "MessageNamedServerConfigurationUpdatedWithValue": "சேவையக உள்ளமைவு பிரிவு {0} புதுப்பிக்கப்பட்டது",
     "MessageNamedServerConfigurationUpdatedWithValue": "சேவையக உள்ளமைவு பிரிவு {0} புதுப்பிக்கப்பட்டது",
     "TaskCleanCacheDescription": "கணினிக்கு இனி தேவைப்படாத தற்காலிக கோப்புகளை நீக்கு.",
     "TaskCleanCacheDescription": "கணினிக்கு இனி தேவைப்படாத தற்காலிக கோப்புகளை நீக்கு.",
     "UserOfflineFromDevice": "{0} இலிருந்து {1} துண்டிக்கப்பட்டுள்ளது",
     "UserOfflineFromDevice": "{0} இலிருந்து {1} துண்டிக்கப்பட்டுள்ளது",
-    "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இலிருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன",
+    "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இல் இருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன",
     "TaskDownloadMissingSubtitlesDescription": "மீத்தரவு உள்ளமைவின் அடிப்படையில் வசன வரிகள் காணாமல் போனதற்கு இணையத்தைத் தேடுகிறது.",
     "TaskDownloadMissingSubtitlesDescription": "மீத்தரவு உள்ளமைவின் அடிப்படையில் வசன வரிகள் காணாமல் போனதற்கு இணையத்தைத் தேடுகிறது.",
     "TaskCleanTranscodeDescription": "ஒரு நாளைக்கு மேற்பட்ட பழைய டிரான்ஸ்கோட் கோப்புகளை நீக்குகிறது.",
     "TaskCleanTranscodeDescription": "ஒரு நாளைக்கு மேற்பட்ட பழைய டிரான்ஸ்கோட் கோப்புகளை நீக்குகிறது.",
     "TaskUpdatePluginsDescription": "தானாகவே புதுப்பிக்க கட்டமைக்கப்பட்ட உட்செருகிகளுக்கான புதுப்பிப்புகளை பதிவிறக்குகிறது மற்றும் நிறுவுகிறது.",
     "TaskUpdatePluginsDescription": "தானாகவே புதுப்பிக்க கட்டமைக்கப்பட்ட உட்செருகிகளுக்கான புதுப்பிப்புகளை பதிவிறக்குகிறது மற்றும் நிறுவுகிறது.",

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

@@ -12,7 +12,7 @@
     "DeviceOfflineWithName": "{0} bağlantısı kesildi",
     "DeviceOfflineWithName": "{0} bağlantısı kesildi",
     "DeviceOnlineWithName": "{0} bağlı",
     "DeviceOnlineWithName": "{0} bağlı",
     "FailedLoginAttemptWithUserName": "{0} adresinden giriş başarısız oldu",
     "FailedLoginAttemptWithUserName": "{0} adresinden giriş başarısız oldu",
-    "Favorites": "Favoriler",
+    "Favorites": "Favorilerim",
     "Folders": "Klasörler",
     "Folders": "Klasörler",
     "Genres": "Türler",
     "Genres": "Türler",
     "HeaderAlbumArtists": "Albüm Sanatçıları",
     "HeaderAlbumArtists": "Albüm Sanatçıları",
@@ -115,5 +115,7 @@
     "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar",
     "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar",
     "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.",
     "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.",
     "TaskCleanActivityLog": "İşlem Günlüğünü Temizle",
     "TaskCleanActivityLog": "İşlem Günlüğünü Temizle",
-    "TaskCleanActivityLogDescription": "Belirtilen sureden daha eski etkinlik log kayıtları silindi."
+    "TaskCleanActivityLogDescription": "Belirtilen sureden daha eski etkinlik log kayıtları silindi.",
+    "Undefined": "Bilinmeyen",
+    "Default": "Varsayılan"
 }
 }

+ 7 - 2
Emby.Server.Implementations/Localization/Core/uk.json

@@ -27,7 +27,7 @@
     "Channels": "Канали",
     "Channels": "Канали",
     "CameraImageUploadedFrom": "Нова фотографія завантажена з {0}",
     "CameraImageUploadedFrom": "Нова фотографія завантажена з {0}",
     "Books": "Книги",
     "Books": "Книги",
-    "AuthenticationSucceededWithUserName": "{0} успішно авторизований",
+    "AuthenticationSucceededWithUserName": "{0} успішно автентифіковано",
     "Artists": "Виконавці",
     "Artists": "Виконавці",
     "Application": "Додаток",
     "Application": "Додаток",
     "AppDeviceValues": "Додаток: {0}, Пристрій: {1}",
     "AppDeviceValues": "Додаток: {0}, Пристрій: {1}",
@@ -112,5 +112,10 @@
     "MessageServerConfigurationUpdated": "Конфігурація сервера оновлена",
     "MessageServerConfigurationUpdated": "Конфігурація сервера оновлена",
     "MessageNamedServerConfigurationUpdatedWithValue": "Розділ конфігурації сервера {0} оновлено",
     "MessageNamedServerConfigurationUpdatedWithValue": "Розділ конфігурації сервера {0} оновлено",
     "Inherit": "Успадкувати",
     "Inherit": "Успадкувати",
-    "HeaderRecordingGroups": "Групи запису"
+    "HeaderRecordingGroups": "Групи запису",
+    "Forced": "Примусово",
+    "TaskCleanActivityLogDescription": "Видаляє старші за встановлений термін записи з журналу активності.",
+    "TaskCleanActivityLog": "Очистити журнал активності",
+    "Undefined": "Не визначено",
+    "Default": "За замовчуванням"
 }
 }

+ 6 - 6
Emby.Server.Implementations/Localization/Core/vi.json

@@ -16,7 +16,7 @@
     "Albums": "Albums",
     "Albums": "Albums",
     "Artists": "Các Nghệ Sĩ",
     "Artists": "Các Nghệ Sĩ",
     "TaskDownloadMissingSubtitlesDescription": "Tìm kiếm phụ đề bị thiếu trên Internet dựa trên cấu hình dữ liệu mô tả.",
     "TaskDownloadMissingSubtitlesDescription": "Tìm kiếm phụ đề bị thiếu trên Internet dựa trên cấu hình dữ liệu mô tả.",
-    "TaskDownloadMissingSubtitles": "Tải xuống phụ đề bị thiếu",
+    "TaskDownloadMissingSubtitles": "Tải Xuống Phụ Đề Bị Thiếu",
     "TaskRefreshChannelsDescription": "Làm mới thông tin kênh internet.",
     "TaskRefreshChannelsDescription": "Làm mới thông tin kênh internet.",
     "TaskRefreshChannels": "Làm Mới Kênh",
     "TaskRefreshChannels": "Làm Mới Kênh",
     "TaskCleanTranscodeDescription": "Xóa các tệp chuyển mã cũ hơn một ngày.",
     "TaskCleanTranscodeDescription": "Xóa các tệp chuyển mã cũ hơn một ngày.",
@@ -24,11 +24,11 @@
     "TaskUpdatePluginsDescription": "Tải xuống và cài đặt các bản cập nhật cho các plugin được định cấu hình để cập nhật tự động.",
     "TaskUpdatePluginsDescription": "Tải xuống và cài đặt các bản cập nhật cho các plugin được định cấu hình để cập nhật tự động.",
     "TaskUpdatePlugins": "Cập Nhật Plugins",
     "TaskUpdatePlugins": "Cập Nhật Plugins",
     "TaskRefreshPeopleDescription": "Cập nhật thông tin chi tiết cho diễn viên và đạo diễn trong thư viện phương tiện của bạn.",
     "TaskRefreshPeopleDescription": "Cập nhật thông tin chi tiết cho diễn viên và đạo diễn trong thư viện phương tiện của bạn.",
-    "TaskRefreshPeople": "Làm mới Người dùng",
+    "TaskRefreshPeople": "Làm Mới Người Dùng",
     "TaskCleanLogsDescription": "Xóa tập tin nhật ký cũ hơn {0} ngày.",
     "TaskCleanLogsDescription": "Xóa tập tin nhật ký cũ hơn {0} ngày.",
-    "TaskCleanLogs": "Làm sạch nhật ký",
-    "TaskRefreshLibraryDescription": "Quét thư viện phương tiện của bạn để tìm các tệp mới và làm mới thông tin chi tiết.",
-    "TaskRefreshLibrary": "Quét Thư viện Phương tiện",
+    "TaskCleanLogs": "Làm Sạch Thư Mục Nhật Ký",
+    "TaskRefreshLibraryDescription": "Quét thư viện phương tiện của bạn để tìm tệp mới và làm mới dữ liệu mô tả.",
+    "TaskRefreshLibrary": "Quét Thư Viện Phương Tiện",
     "TaskRefreshChapterImagesDescription": "Tạo hình thu nhỏ cho video có các phân cảnh.",
     "TaskRefreshChapterImagesDescription": "Tạo hình thu nhỏ cho video có các phân cảnh.",
     "TaskRefreshChapterImages": "Trích Xuất Ảnh Phân Cảnh",
     "TaskRefreshChapterImages": "Trích Xuất Ảnh Phân Cảnh",
     "TaskCleanCacheDescription": "Xóa các tệp cache không còn cần thiết của hệ thống.",
     "TaskCleanCacheDescription": "Xóa các tệp cache không còn cần thiết của hệ thống.",
@@ -80,7 +80,7 @@
     "NotificationOptionApplicationUpdateAvailable": "Bản cập nhật ứng dụng hiện sẵn có",
     "NotificationOptionApplicationUpdateAvailable": "Bản cập nhật ứng dụng hiện sẵn có",
     "NewVersionIsAvailable": "Một phiên bản mới của Jellyfin Server sẵn có để tải.",
     "NewVersionIsAvailable": "Một phiên bản mới của Jellyfin Server sẵn có để tải.",
     "NameSeasonUnknown": "Không Rõ Mùa",
     "NameSeasonUnknown": "Không Rõ Mùa",
-    "NameSeasonNumber": "Mùa {0}",
+    "NameSeasonNumber": "Phần {0}",
     "NameInstallFailed": "{0} cài đặt thất bại",
     "NameInstallFailed": "{0} cài đặt thất bại",
     "MusicVideos": "Video Nhạc",
     "MusicVideos": "Video Nhạc",
     "Music": "Nhạc",
     "Music": "Nhạc",

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

@@ -114,5 +114,8 @@
     "TasksApplicationCategory": "應用程式",
     "TasksApplicationCategory": "應用程式",
     "TasksMaintenanceCategory": "維護",
     "TasksMaintenanceCategory": "維護",
     "TaskCleanActivityLogDescription": "刪除超過所設時間的活動紀錄。",
     "TaskCleanActivityLogDescription": "刪除超過所設時間的活動紀錄。",
-    "TaskCleanActivityLog": "清除活動紀錄"
+    "TaskCleanActivityLog": "清除活動紀錄",
+    "Undefined": "未定義的",
+    "Forced": "強制",
+    "Default": "原本"
 }
 }

+ 6 - 0
Emby.Server.Implementations/Localization/countries.json

@@ -557,6 +557,12 @@
         "ThreeLetterISORegionName": "OMN",
         "ThreeLetterISORegionName": "OMN",
         "TwoLetterISORegionName": "OM"
         "TwoLetterISORegionName": "OM"
     },
     },
+    {
+        "DisplayName": "Palestine",
+        "Name": "PS",
+        "ThreeLetterISORegionName": "PSE",
+        "TwoLetterISORegionName": "PS"
+    },
     {
     {
         "DisplayName": "Panama",
         "DisplayName": "Panama",
         "Name": "PA",
         "Name": "PA",

+ 10 - 10
Emby.Server.Implementations/MediaEncoder/EncodingManager.cs

@@ -12,6 +12,7 @@ using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.MediaInfo;
 using MediaBrowser.Model.MediaInfo;
@@ -81,12 +82,7 @@ namespace Emby.Server.Implementations.MediaEncoder
                 return false;
                 return false;
             }
             }
 
 
-            if (video.VideoType == VideoType.Iso)
-            {
-                return false;
-            }
-
-            if (video.VideoType == VideoType.BluRay || video.VideoType == VideoType.Dvd)
+            if (video.VideoType == VideoType.Dvd)
             {
             {
                 return false;
                 return false;
             }
             }
@@ -140,15 +136,19 @@ namespace Emby.Server.Implementations.MediaEncoder
                             // Add some time for the first chapter to make sure we don't end up with a black image
                             // Add some time for the first chapter to make sure we don't end up with a black image
                             var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(_firstChapterTicks, video.RunTimeTicks ?? 0)) : TimeSpan.FromTicks(chapter.StartPositionTicks);
                             var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(_firstChapterTicks, video.RunTimeTicks ?? 0)) : TimeSpan.FromTicks(chapter.StartPositionTicks);
 
 
-                            var protocol = MediaProtocol.File;
-
-                            var inputPath = MediaEncoderHelpers.GetInputArgument(_fileSystem, video.Path, null, Array.Empty<string>());
+                            var inputPath = video.Path;
 
 
                             Directory.CreateDirectory(Path.GetDirectoryName(path));
                             Directory.CreateDirectory(Path.GetDirectoryName(path));
 
 
                             var container = video.Container;
                             var container = video.Container;
+                            var mediaSource = new MediaSourceInfo
+                            {
+                                VideoType = video.VideoType,
+                                IsoType = video.IsoType,
+                                Protocol = video.PathProtocol.Value,
+                            };
 
 
-                            var tempFile = await _encoder.ExtractVideoImage(inputPath, container, protocol, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false);
+                            var tempFile = await _encoder.ExtractVideoImage(inputPath, container, mediaSource, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false);
                             File.Copy(tempFile, path, true);
                             File.Copy(tempFile, path, true);
 
 
                             try
                             try

+ 0 - 556
Emby.Server.Implementations/Networking/NetworkManager.cs

@@ -1,556 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net;
-using System.Net.NetworkInformation;
-using System.Net.Sockets;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using Microsoft.Extensions.Logging;
-
-namespace Emby.Server.Implementations.Networking
-{
-    /// <summary>
-    /// Class to take care of network interface management.
-    /// </summary>
-    public class NetworkManager : INetworkManager
-    {
-        private readonly ILogger<NetworkManager> _logger;
-
-        private IPAddress[] _localIpAddresses;
-        private readonly object _localIpAddressSyncLock = new object();
-
-        private readonly object _subnetLookupLock = new object();
-        private readonly Dictionary<string, List<string>> _subnetLookup = new Dictionary<string, List<string>>(StringComparer.Ordinal);
-
-        private List<PhysicalAddress> _macAddresses;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="NetworkManager"/> class.
-        /// </summary>
-        /// <param name="logger">Logger to use for messages.</param>
-        public NetworkManager(ILogger<NetworkManager> logger)
-        {
-            _logger = logger;
-
-            NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged;
-            NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged;
-        }
-
-        /// <inheritdoc/>
-        public event EventHandler NetworkChanged;
-
-        /// <inheritdoc/>
-        public Func<string[]> LocalSubnetsFn { get; set; }
-
-        private void OnNetworkAvailabilityChanged(object sender, NetworkAvailabilityEventArgs e)
-        {
-            _logger.LogDebug("NetworkAvailabilityChanged");
-            OnNetworkChanged();
-        }
-
-        private void OnNetworkAddressChanged(object sender, EventArgs e)
-        {
-            _logger.LogDebug("NetworkAddressChanged");
-            OnNetworkChanged();
-        }
-
-        private void OnNetworkChanged()
-        {
-            lock (_localIpAddressSyncLock)
-            {
-                _localIpAddresses = null;
-                _macAddresses = null;
-            }
-
-            NetworkChanged?.Invoke(this, EventArgs.Empty);
-        }
-
-        /// <inheritdoc/>
-        public IPAddress[] GetLocalIpAddresses()
-        {
-            lock (_localIpAddressSyncLock)
-            {
-                if (_localIpAddresses == null)
-                {
-                    var addresses = GetLocalIpAddressesInternal().ToArray();
-
-                    _localIpAddresses = addresses;
-                }
-
-                return _localIpAddresses;
-            }
-        }
-
-        private List<IPAddress> GetLocalIpAddressesInternal()
-        {
-            var list = GetIPsDefault().ToList();
-
-            if (list.Count == 0)
-            {
-                list = GetLocalIpAddressesFallback().GetAwaiter().GetResult().ToList();
-            }
-
-            var listClone = new List<IPAddress>();
-
-            var subnets = LocalSubnetsFn();
-
-            foreach (var i in list)
-            {
-                if (i.IsIPv6LinkLocal || i.ToString().StartsWith("169.254.", StringComparison.OrdinalIgnoreCase))
-                {
-                    continue;
-                }
-
-                if (Array.IndexOf(subnets, $"[{i}]") == -1)
-                {
-                    listClone.Add(i);
-                }
-            }
-
-            return listClone
-                .OrderBy(i => i.AddressFamily == AddressFamily.InterNetwork ? 0 : 1)
-                // .ThenBy(i => listClone.IndexOf(i))
-                .GroupBy(i => i.ToString())
-                .Select(x => x.First())
-                .ToList();
-        }
-
-        /// <inheritdoc/>
-        public bool IsInPrivateAddressSpace(string endpoint)
-        {
-            return IsInPrivateAddressSpace(endpoint, true);
-        }
-
-        // Checks if the address in endpoint is an RFC1918, RFC1122, or RFC3927 address
-        private bool IsInPrivateAddressSpace(string endpoint, bool checkSubnets)
-        {
-            if (string.Equals(endpoint, "::1", StringComparison.OrdinalIgnoreCase))
-            {
-                return true;
-            }
-
-            // IPV6
-            if (endpoint.Split('.').Length > 4)
-            {
-                // Handle ipv4 mapped to ipv6
-                var originalEndpoint = endpoint;
-                endpoint = endpoint.Replace("::ffff:", string.Empty, StringComparison.OrdinalIgnoreCase);
-
-                if (string.Equals(endpoint, originalEndpoint, StringComparison.OrdinalIgnoreCase))
-                {
-                    return false;
-                }
-            }
-
-            // Private address space:
-
-            if (string.Equals(endpoint, "localhost", StringComparison.OrdinalIgnoreCase))
-            {
-                return true;
-            }
-
-            if (!IPAddress.TryParse(endpoint, out var ipAddress))
-            {
-                return false;
-            }
-
-            byte[] octet = ipAddress.GetAddressBytes();
-
-            if ((octet[0] == 10) ||
-                (octet[0] == 172 && (octet[1] >= 16 && octet[1] <= 31)) || // RFC1918
-                (octet[0] == 192 && octet[1] == 168) || // RFC1918
-                (octet[0] == 127) || // RFC1122
-                (octet[0] == 169 && octet[1] == 254)) // RFC3927
-            {
-                return true;
-            }
-
-            if (checkSubnets && IsInPrivateAddressSpaceAndLocalSubnet(endpoint))
-            {
-                return true;
-            }
-
-            return false;
-        }
-
-        /// <inheritdoc/>
-        public bool IsInPrivateAddressSpaceAndLocalSubnet(string endpoint)
-        {
-            if (endpoint.StartsWith("10.", StringComparison.OrdinalIgnoreCase))
-            {
-                var endpointFirstPart = endpoint.Split('.')[0];
-
-                var subnets = GetSubnets(endpointFirstPart);
-
-                foreach (var subnet_Match in subnets)
-                {
-                    // logger.LogDebug("subnet_Match:" + subnet_Match);
-
-                    if (endpoint.StartsWith(subnet_Match + ".", StringComparison.OrdinalIgnoreCase))
-                    {
-                        return true;
-                    }
-                }
-            }
-
-            return false;
-        }
-
-        // Gives a list of possible subnets from the system whose interface ip starts with endpointFirstPart
-        private List<string> GetSubnets(string endpointFirstPart)
-        {
-            lock (_subnetLookupLock)
-            {
-                if (_subnetLookup.TryGetValue(endpointFirstPart, out var subnets))
-                {
-                    return subnets;
-                }
-
-                subnets = new List<string>();
-
-                foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces())
-                {
-                    foreach (var unicastIPAddressInformation in adapter.GetIPProperties().UnicastAddresses)
-                    {
-                        if (unicastIPAddressInformation.Address.AddressFamily == AddressFamily.InterNetwork && endpointFirstPart == unicastIPAddressInformation.Address.ToString().Split('.')[0])
-                        {
-                            int subnet_Test = 0;
-                            foreach (string part in unicastIPAddressInformation.IPv4Mask.ToString().Split('.'))
-                            {
-                                if (part.Equals("0", StringComparison.Ordinal))
-                                {
-                                    break;
-                                }
-
-                                subnet_Test++;
-                            }
-
-                            var subnet_Match = string.Join(".", unicastIPAddressInformation.Address.ToString().Split('.').Take(subnet_Test).ToArray());
-
-                            // TODO: Is this check necessary?
-                            if (adapter.OperationalStatus == OperationalStatus.Up)
-                            {
-                                subnets.Add(subnet_Match);
-                            }
-                        }
-                    }
-                }
-
-                _subnetLookup[endpointFirstPart] = subnets;
-
-                return subnets;
-            }
-        }
-
-        /// <inheritdoc/>
-        public bool IsInLocalNetwork(string endpoint)
-        {
-            return IsInLocalNetworkInternal(endpoint, true);
-        }
-
-        /// <inheritdoc/>
-        public bool IsAddressInSubnets(string addressString, string[] subnets)
-        {
-            return IsAddressInSubnets(IPAddress.Parse(addressString), addressString, subnets);
-        }
-
-        /// <inheritdoc/>
-        public bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC)
-        {
-            byte[] octet = address.GetAddressBytes();
-
-            if ((octet[0] == 127) || // RFC1122
-                (octet[0] == 169 && octet[1] == 254)) // RFC3927
-            {
-                // don't use on loopback or 169 interfaces
-                return false;
-            }
-
-            string addressString = address.ToString();
-            string excludeAddress = "[" + addressString + "]";
-            var subnets = LocalSubnetsFn();
-
-            // Include any address if LAN subnets aren't specified
-            if (subnets.Length == 0)
-            {
-                return true;
-            }
-
-            // Exclude any addresses if they appear in the LAN list in [ ]
-            if (Array.IndexOf(subnets, excludeAddress) != -1)
-            {
-                return false;
-            }
-
-            return IsAddressInSubnets(address, addressString, subnets);
-        }
-
-        /// <summary>
-        /// Checks if the give address falls within the ranges given in [subnets]. The addresses in subnets can be hosts or subnets in the CIDR format.
-        /// </summary>
-        /// <param name="address">IPAddress version of the address.</param>
-        /// <param name="addressString">The address to check.</param>
-        /// <param name="subnets">If true, check against addresses in the LAN settings which have [] arroud and return true if it matches the address give in address.</param>
-        /// <returns><c>false</c>if the address isn't in the subnets, <c>true</c> otherwise.</returns>
-        private static bool IsAddressInSubnets(IPAddress address, string addressString, string[] subnets)
-        {
-            foreach (var subnet in subnets)
-            {
-                var normalizedSubnet = subnet.Trim();
-                // Is the subnet a host address and does it match the address being passes?
-                if (string.Equals(normalizedSubnet, addressString, StringComparison.OrdinalIgnoreCase))
-                {
-                    return true;
-                }
-
-                // Parse CIDR subnets and see if address falls within it.
-                if (normalizedSubnet.Contains('/', StringComparison.Ordinal))
-                {
-                    try
-                    {
-                        var ipNetwork = IPNetwork.Parse(normalizedSubnet);
-                        if (ipNetwork.Contains(address))
-                        {
-                            return true;
-                        }
-                    }
-                    catch
-                    {
-                        // Ignoring - invalid subnet passed encountered.
-                    }
-                }
-            }
-
-            return false;
-        }
-
-        private bool IsInLocalNetworkInternal(string endpoint, bool resolveHost)
-        {
-            if (string.IsNullOrEmpty(endpoint))
-            {
-                throw new ArgumentNullException(nameof(endpoint));
-            }
-
-            if (IPAddress.TryParse(endpoint, out var address))
-            {
-                var addressString = address.ToString();
-
-                var localSubnetsFn = LocalSubnetsFn;
-                if (localSubnetsFn != null)
-                {
-                    var localSubnets = localSubnetsFn();
-                    foreach (var subnet in localSubnets)
-                    {
-                        // Only validate if there's at least one valid entry.
-                        if (!string.IsNullOrWhiteSpace(subnet))
-                        {
-                            return IsAddressInSubnets(address, addressString, localSubnets) || IsInPrivateAddressSpace(addressString, false);
-                        }
-                    }
-                }
-
-                int lengthMatch = 100;
-                if (address.AddressFamily == AddressFamily.InterNetwork)
-                {
-                    lengthMatch = 4;
-                    if (IsInPrivateAddressSpace(addressString, true))
-                    {
-                        return true;
-                    }
-                }
-                else if (address.AddressFamily == AddressFamily.InterNetworkV6)
-                {
-                    lengthMatch = 9;
-                    if (IsInPrivateAddressSpace(endpoint, true))
-                    {
-                        return true;
-                    }
-                }
-
-                // Should be even be doing this with ipv6?
-                if (addressString.Length >= lengthMatch)
-                {
-                    var prefix = addressString.Substring(0, lengthMatch);
-
-                    if (GetLocalIpAddresses().Any(i => i.ToString().StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
-                    {
-                        return true;
-                    }
-                }
-            }
-            else if (resolveHost)
-            {
-                if (Uri.TryCreate(endpoint, UriKind.RelativeOrAbsolute, out var uri))
-                {
-                    try
-                    {
-                        var host = uri.DnsSafeHost;
-                        _logger.LogDebug("Resolving host {0}", host);
-
-                        address = GetIpAddresses(host).GetAwaiter().GetResult().FirstOrDefault();
-
-                        if (address != null)
-                        {
-                            _logger.LogDebug("{0} resolved to {1}", host, address);
-
-                            return IsInLocalNetworkInternal(address.ToString(), false);
-                        }
-                    }
-                    catch (InvalidOperationException)
-                    {
-                        // Can happen with reverse proxy or IIS url rewriting?
-                    }
-                    catch (Exception ex)
-                    {
-                        _logger.LogError(ex, "Error resolving hostname");
-                    }
-                }
-            }
-
-            return false;
-        }
-
-        private static Task<IPAddress[]> GetIpAddresses(string hostName)
-        {
-            return Dns.GetHostAddressesAsync(hostName);
-        }
-
-        private IEnumerable<IPAddress> GetIPsDefault()
-        {
-            IEnumerable<NetworkInterface> interfaces;
-
-            try
-            {
-                interfaces = NetworkInterface.GetAllNetworkInterfaces()
-                    .Where(x => x.OperationalStatus == OperationalStatus.Up
-                        || x.OperationalStatus == OperationalStatus.Unknown);
-            }
-            catch (NetworkInformationException ex)
-            {
-                _logger.LogError(ex, "Error in GetAllNetworkInterfaces");
-                return Enumerable.Empty<IPAddress>();
-            }
-
-            return interfaces.SelectMany(network =>
-            {
-                var ipProperties = network.GetIPProperties();
-
-                // Exclude any addresses if they appear in the LAN list in [ ]
-
-                return ipProperties.UnicastAddresses
-                    .Select(i => i.Address)
-                    .Where(i => i.AddressFamily == AddressFamily.InterNetwork || i.AddressFamily == AddressFamily.InterNetworkV6);
-            }).GroupBy(i => i.ToString())
-                .Select(x => x.First());
-        }
-
-        private static async Task<IEnumerable<IPAddress>> GetLocalIpAddressesFallback()
-        {
-            var host = await Dns.GetHostEntryAsync(Dns.GetHostName()).ConfigureAwait(false);
-
-            // Reverse them because the last one is usually the correct one
-            // It's not fool-proof so ultimately the consumer will have to examine them and decide
-            return host.AddressList
-                .Where(i => i.AddressFamily == AddressFamily.InterNetwork || i.AddressFamily == AddressFamily.InterNetworkV6)
-                .Reverse();
-        }
-
-        /// <summary>
-        /// Gets a random port number that is currently available.
-        /// </summary>
-        /// <returns>System.Int32.</returns>
-        public int GetRandomUnusedTcpPort()
-        {
-            var listener = new TcpListener(IPAddress.Any, 0);
-            listener.Start();
-            var port = ((IPEndPoint)listener.LocalEndpoint).Port;
-            listener.Stop();
-            return port;
-        }
-
-        /// <inheritdoc/>
-        public int GetRandomUnusedUdpPort()
-        {
-            var localEndPoint = new IPEndPoint(IPAddress.Any, 0);
-            using (var udpClient = new UdpClient(localEndPoint))
-            {
-                return ((IPEndPoint)udpClient.Client.LocalEndPoint).Port;
-            }
-        }
-
-        /// <inheritdoc/>
-        public List<PhysicalAddress> GetMacAddresses()
-        {
-            return _macAddresses ??= GetMacAddressesInternal().ToList();
-        }
-
-        private static IEnumerable<PhysicalAddress> GetMacAddressesInternal()
-            => NetworkInterface.GetAllNetworkInterfaces()
-                .Where(i => i.NetworkInterfaceType != NetworkInterfaceType.Loopback)
-                .Select(x => x.GetPhysicalAddress())
-                .Where(x => !x.Equals(PhysicalAddress.None));
-
-        /// <inheritdoc/>
-        public bool IsInSameSubnet(IPAddress address1, IPAddress address2, IPAddress subnetMask)
-        {
-            IPAddress network1 = GetNetworkAddress(address1, subnetMask);
-            IPAddress network2 = GetNetworkAddress(address2, subnetMask);
-            return network1.Equals(network2);
-        }
-
-        private IPAddress GetNetworkAddress(IPAddress address, IPAddress subnetMask)
-        {
-            byte[] ipAdressBytes = address.GetAddressBytes();
-            byte[] subnetMaskBytes = subnetMask.GetAddressBytes();
-
-            if (ipAdressBytes.Length != subnetMaskBytes.Length)
-            {
-                throw new ArgumentException("Lengths of IP address and subnet mask do not match.");
-            }
-
-            byte[] broadcastAddress = new byte[ipAdressBytes.Length];
-            for (int i = 0; i < broadcastAddress.Length; i++)
-            {
-                broadcastAddress[i] = (byte)(ipAdressBytes[i] & subnetMaskBytes[i]);
-            }
-
-            return new IPAddress(broadcastAddress);
-        }
-
-         /// <inheritdoc/>
-        public IPAddress GetLocalIpSubnetMask(IPAddress address)
-        {
-            NetworkInterface[] interfaces;
-
-            try
-            {
-                var validStatuses = new[] { OperationalStatus.Up, OperationalStatus.Unknown };
-
-                interfaces = NetworkInterface.GetAllNetworkInterfaces()
-                    .Where(i => validStatuses.Contains(i.OperationalStatus))
-                    .ToArray();
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error in GetAllNetworkInterfaces");
-                return null;
-            }
-
-            foreach (NetworkInterface ni in interfaces)
-            {
-                foreach (UnicastIPAddressInformation ip in ni.GetIPProperties().UnicastAddresses)
-                {
-                    if (ip.Address.Equals(address) && ip.IPv4Mask != null)
-                    {
-                        return ip.IPv4Mask;
-                    }
-                }
-            }
-
-            return null;
-        }
-    }
-}

+ 3 - 3
Emby.Server.Implementations/Playlists/PlaylistManager.cs

@@ -150,7 +150,7 @@ namespace Emby.Server.Implementations.Playlists
                 await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None)
                 await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None)
                     .ConfigureAwait(false);
                     .ConfigureAwait(false);
 
 
-                if (options.ItemIdList.Length > 0)
+                if (options.ItemIdList.Count > 0)
                 {
                 {
                     await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false)
                     await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false)
                     {
                     {
@@ -184,7 +184,7 @@ namespace Emby.Server.Implementations.Playlists
             return Playlist.GetPlaylistItems(playlistMediaType, items, user, options);
             return Playlist.GetPlaylistItems(playlistMediaType, items, user, options);
         }
         }
 
 
-        public Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId)
+        public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId)
         {
         {
             var user = userId.Equals(Guid.Empty) ? null : _userManager.GetUserById(userId);
             var user = userId.Equals(Guid.Empty) ? null : _userManager.GetUserById(userId);
 
 
@@ -194,7 +194,7 @@ namespace Emby.Server.Implementations.Playlists
             });
             });
         }
         }
 
 
-        private async Task AddToPlaylistInternal(Guid playlistId, ICollection<Guid> newItemIds, User user, DtoOptions options)
+        private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options)
         {
         {
             // Retrieve the existing playlist
             // Retrieve the existing playlist
             var playlist = _libraryManager.GetItemById(playlistId) as Playlist
             var playlist = _libraryManager.GetItemById(playlistId) as Playlist

+ 1 - 1
Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs

@@ -243,7 +243,7 @@ namespace Emby.Server.Implementations.QuickConnect
             Span<byte> bytes = stackalloc byte[length];
             Span<byte> bytes = stackalloc byte[length];
             _rng.GetBytes(bytes);
             _rng.GetBytes(bytes);
 
 
-            return Hex.Encode(bytes);
+            return Convert.ToHexString(bytes);
         }
         }
 
 
         /// <inheritdoc/>
         /// <inheritdoc/>

+ 0 - 45
Emby.Server.Implementations/ResourceFileManager.cs

@@ -1,45 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.IO;
-using MediaBrowser.Controller;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.IO;
-using Microsoft.Extensions.Logging;
-
-namespace Emby.Server.Implementations
-{
-    public class ResourceFileManager : IResourceFileManager
-    {
-        private readonly IFileSystem _fileSystem;
-        private readonly ILogger<ResourceFileManager> _logger;
-
-        public ResourceFileManager(ILogger<ResourceFileManager> logger, IFileSystem fileSystem)
-        {
-            _logger = logger;
-            _fileSystem = fileSystem;
-        }
-
-        public string GetResourcePath(string basePath, string virtualPath)
-        {
-            var fullPath = Path.Combine(basePath, virtualPath.Replace('/', Path.DirectorySeparatorChar));
-
-            try
-            {
-                fullPath = Path.GetFullPath(fullPath);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error retrieving full path");
-            }
-
-            // Don't allow file system access outside of the source folder
-            if (!_fileSystem.ContainsSubPath(basePath, fullPath))
-            {
-                throw new SecurityException("Access denied");
-            }
-
-            return fullPath;
-        }
-    }
-}

+ 38 - 24
Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs

@@ -5,10 +5,10 @@ using System.Linq;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Tasks;
 using MediaBrowser.Model.Tasks;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
-using MediaBrowser.Model.Globalization;
 
 
 namespace Emby.Server.Implementations.ScheduledTasks.Tasks
 namespace Emby.Server.Implementations.ScheduledTasks.Tasks
 {
 {
@@ -23,8 +23,12 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
         private readonly ILocalizationManager _localization;
         private readonly ILocalizationManager _localization;
 
 
         /// <summary>
         /// <summary>
-        /// Initializes a new instance of the <see cref="DeleteTranscodeFileTask" /> class.
+        /// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
         /// </summary>
         /// </summary>
+        /// <param name="logger">Instance of the <see cref="ILogger{DeleteTranscodeFileTask}"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+        /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
         public DeleteTranscodeFileTask(
         public DeleteTranscodeFileTask(
             ILogger<DeleteTranscodeFileTask> logger,
             ILogger<DeleteTranscodeFileTask> logger,
             IFileSystem fileSystem,
             IFileSystem fileSystem,
@@ -37,11 +41,42 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
             _localization = localization;
             _localization = localization;
         }
         }
 
 
+        /// <inheritdoc />
+        public string Name => _localization.GetLocalizedString("TaskCleanTranscode");
+
+        /// <inheritdoc />
+        public string Description => _localization.GetLocalizedString("TaskCleanTranscodeDescription");
+
+        /// <inheritdoc />
+        public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
+
+        /// <inheritdoc />
+        public string Key => "DeleteTranscodeFiles";
+
+        /// <inheritdoc />
+        public bool IsHidden => false;
+
+        /// <inheritdoc />
+        public bool IsEnabled => true;
+
+        /// <inheritdoc />
+        public bool IsLogged => true;
+
         /// <summary>
         /// <summary>
         /// Creates the triggers that define when the task will run.
         /// Creates the triggers that define when the task will run.
         /// </summary>
         /// </summary>
         /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
         /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
-        public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => new List<TaskTriggerInfo>();
+        public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+        {
+            return new[]
+            {
+                new TaskTriggerInfo
+                {
+                    Type = TaskTriggerInfo.TriggerInterval,
+                    IntervalTicks = TimeSpan.FromHours(24).Ticks
+                }
+            };
+        }
 
 
         /// <summary>
         /// <summary>
         /// Returns the task to be executed.
         /// Returns the task to be executed.
@@ -131,26 +166,5 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
                 _logger.LogError(ex, "Error deleting file {path}", path);
                 _logger.LogError(ex, "Error deleting file {path}", path);
             }
             }
         }
         }
-
-        /// <inheritdoc />
-        public string Name => _localization.GetLocalizedString("TaskCleanTranscode");
-
-        /// <inheritdoc />
-        public string Description => _localization.GetLocalizedString("TaskCleanTranscodeDescription");
-
-        /// <inheritdoc />
-        public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
-
-        /// <inheritdoc />
-        public string Key => "DeleteTranscodeFiles";
-
-        /// <inheritdoc />
-        public bool IsHidden => false;
-
-        /// <inheritdoc />
-        public bool IsEnabled => true;
-
-        /// <inheritdoc />
-        public bool IsLogged => true;
     }
     }
 }
 }

+ 3 - 21
Emby.Server.Implementations/Session/SessionManager.cs

@@ -58,8 +58,7 @@ namespace Emby.Server.Implementations.Session
         /// <summary>
         /// <summary>
         /// The active connections.
         /// The active connections.
         /// </summary>
         /// </summary>
-        private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections =
-            new ConcurrentDictionary<string, SessionInfo>(StringComparer.OrdinalIgnoreCase);
+        private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = new (StringComparer.OrdinalIgnoreCase);
 
 
         private Timer _idleTimer;
         private Timer _idleTimer;
 
 
@@ -196,7 +195,7 @@ namespace Emby.Server.Implementations.Session
         {
         {
             if (!string.IsNullOrEmpty(info.DeviceId))
             if (!string.IsNullOrEmpty(info.DeviceId))
             {
             {
-                var capabilities = GetSavedCapabilities(info.DeviceId);
+                var capabilities = _deviceManager.GetCapabilities(info.DeviceId);
 
 
                 if (capabilities != null)
                 if (capabilities != null)
                 {
                 {
@@ -1677,27 +1676,10 @@ namespace Emby.Server.Implementations.Session
                         SessionInfo = session
                         SessionInfo = session
                     });
                     });
 
 
-                try
-                {
-                    SaveCapabilities(session.DeviceId, capabilities);
-                }
-                catch (Exception ex)
-                {
-                    _logger.LogError("Error saving device capabilities", ex);
-                }
+                _deviceManager.SaveCapabilities(session.DeviceId, capabilities);
             }
             }
         }
         }
 
 
-        private ClientCapabilities GetSavedCapabilities(string deviceId)
-        {
-            return _deviceManager.GetCapabilities(deviceId);
-        }
-
-        private void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
-        {
-            _deviceManager.SaveCapabilities(deviceId, capabilities);
-        }
-
         /// <summary>
         /// <summary>
         /// Converts a BaseItem to a BaseItemInfo.
         /// Converts a BaseItem to a BaseItemInfo.
         /// </summary>
         /// </summary>

+ 16 - 24
Emby.Server.Implementations/TV/TVSeriesManager.cs

@@ -56,13 +56,11 @@ namespace Emby.Server.Implementations.TV
                 return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, dtoOptions), request);
                 return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, dtoOptions), request);
             }
             }
 
 
-            var parentIdGuid = string.IsNullOrEmpty(request.ParentId) ? (Guid?)null : new Guid(request.ParentId);
-
             BaseItem[] parents;
             BaseItem[] parents;
 
 
-            if (parentIdGuid.HasValue)
+            if (request.ParentId.HasValue)
             {
             {
-                var parent = _libraryManager.GetItemById(parentIdGuid.Value);
+                var parent = _libraryManager.GetItemById(request.ParentId.Value);
 
 
                 if (parent != null)
                 if (parent != null)
                 {
                 {
@@ -146,28 +144,10 @@ namespace Emby.Server.Implementations.TV
             var allNextUp = seriesKeys
             var allNextUp = seriesKeys
                 .Select(i => GetNextUp(i, currentUser, dtoOptions));
                 .Select(i => GetNextUp(i, currentUser, dtoOptions));
 
 
-            // allNextUp = allNextUp.OrderByDescending(i => i.Item1);
-
-            // If viewing all next up for all series, remove first episodes
-            // But if that returns empty, keep those first episodes (avoid completely empty view)
-            var alwaysEnableFirstEpisode = !string.IsNullOrEmpty(request.SeriesId);
-            var anyFound = false;
-
             return allNextUp
             return allNextUp
                 .Where(i =>
                 .Where(i =>
                 {
                 {
-                    if (alwaysEnableFirstEpisode || i.Item1 != DateTime.MinValue)
-                    {
-                        anyFound = true;
-                        return true;
-                    }
-
-                    if (!anyFound && i.Item1 == DateTime.MinValue)
-                    {
-                        return true;
-                    }
-
-                    return false;
+                    return i.Item1 != DateTime.MinValue;
                 })
                 })
                 .Select(i => i.Item2())
                 .Select(i => i.Item2())
                 .Where(i => i != null);
                 .Where(i => i != null);
@@ -210,7 +190,7 @@ namespace Emby.Server.Implementations.TV
 
 
             Func<Episode> getEpisode = () =>
             Func<Episode> getEpisode = () =>
             {
             {
-                return _libraryManager.GetItemList(new InternalItemsQuery(user)
+                var nextEpisode = _libraryManager.GetItemList(new InternalItemsQuery(user)
                 {
                 {
                     AncestorWithPresentationUniqueKey = null,
                     AncestorWithPresentationUniqueKey = null,
                     SeriesPresentationUniqueKey = seriesKey,
                     SeriesPresentationUniqueKey = seriesKey,
@@ -223,6 +203,18 @@ namespace Emby.Server.Implementations.TV
                     MinSortName = lastWatchedEpisode?.SortName,
                     MinSortName = lastWatchedEpisode?.SortName,
                     DtoOptions = dtoOptions
                     DtoOptions = dtoOptions
                 }).Cast<Episode>().FirstOrDefault();
                 }).Cast<Episode>().FirstOrDefault();
+
+                if (nextEpisode != null)
+                {
+                    var userData = _userDataManager.GetUserData(user, nextEpisode);
+
+                    if (userData.PlaybackPositionTicks > 0)
+                    {
+                        return null;
+                    }
+                }
+
+                return nextEpisode;
             };
             };
 
 
             if (lastWatchedEpisode != null)
             if (lastWatchedEpisode != null)

+ 1 - 1
Emby.Server.Implementations/Udp/UdpServer.cs

@@ -49,7 +49,7 @@ namespace Emby.Server.Implementations.Udp
         {
         {
             string localUrl = !string.IsNullOrEmpty(_config[AddressOverrideConfigKey])
             string localUrl = !string.IsNullOrEmpty(_config[AddressOverrideConfigKey])
                 ? _config[AddressOverrideConfigKey]
                 ? _config[AddressOverrideConfigKey]
-                : await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
+                : _appHost.GetSmartApiUrl(((IPEndPoint)endpoint).Address);
 
 
             if (!string.IsNullOrEmpty(localUrl))
             if (!string.IsNullOrEmpty(localUrl))
             {
             {

+ 101 - 28
Emby.Server.Implementations/Updates/InstallationManager.cs

@@ -6,13 +6,15 @@ using System.Collections.Generic;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
 using System.Net.Http;
 using System.Net.Http;
-using System.Runtime.Serialization;
+using System.Net.Http.Json;
 using System.Security.Cryptography;
 using System.Security.Cryptography;
+using System.Text.Json;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Data.Events;
 using Jellyfin.Data.Events;
 using MediaBrowser.Common;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Json;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Common.Updates;
 using MediaBrowser.Common.Updates;
@@ -21,8 +23,6 @@ using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Events;
 using MediaBrowser.Controller.Events;
 using MediaBrowser.Controller.Events.Updates;
 using MediaBrowser.Controller.Events.Updates;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Net;
-using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Updates;
 using MediaBrowser.Model.Updates;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 
 
@@ -40,9 +40,9 @@ namespace Emby.Server.Implementations.Updates
         private readonly IApplicationPaths _appPaths;
         private readonly IApplicationPaths _appPaths;
         private readonly IEventManager _eventManager;
         private readonly IEventManager _eventManager;
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly IHttpClientFactory _httpClientFactory;
-        private readonly IJsonSerializer _jsonSerializer;
         private readonly IServerConfigurationManager _config;
         private readonly IServerConfigurationManager _config;
         private readonly IFileSystem _fileSystem;
         private readonly IFileSystem _fileSystem;
+        private readonly JsonSerializerOptions _jsonSerializerOptions;
 
 
         /// <summary>
         /// <summary>
         /// Gets the application host.
         /// Gets the application host.
@@ -70,7 +70,6 @@ namespace Emby.Server.Implementations.Updates
             IApplicationPaths appPaths,
             IApplicationPaths appPaths,
             IEventManager eventManager,
             IEventManager eventManager,
             IHttpClientFactory httpClientFactory,
             IHttpClientFactory httpClientFactory,
-            IJsonSerializer jsonSerializer,
             IServerConfigurationManager config,
             IServerConfigurationManager config,
             IFileSystem fileSystem,
             IFileSystem fileSystem,
             IZipClient zipClient)
             IZipClient zipClient)
@@ -83,33 +82,43 @@ namespace Emby.Server.Implementations.Updates
             _appPaths = appPaths;
             _appPaths = appPaths;
             _eventManager = eventManager;
             _eventManager = eventManager;
             _httpClientFactory = httpClientFactory;
             _httpClientFactory = httpClientFactory;
-            _jsonSerializer = jsonSerializer;
             _config = config;
             _config = config;
             _fileSystem = fileSystem;
             _fileSystem = fileSystem;
             _zipClient = zipClient;
             _zipClient = zipClient;
+            _jsonSerializerOptions = JsonDefaults.GetOptions();
         }
         }
 
 
         /// <inheritdoc />
         /// <inheritdoc />
         public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal;
         public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal;
 
 
         /// <inheritdoc />
         /// <inheritdoc />
-        public async Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default)
+        public async Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default)
         {
         {
             try
             try
             {
             {
-                using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
-                    .GetAsync(manifest, cancellationToken).ConfigureAwait(false);
-                await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-
-                try
+                var packages = await _httpClientFactory.CreateClient(NamedClient.Default)
+                    .GetFromJsonAsync<List<PackageInfo>>(new Uri(manifest), _jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
+                if (packages == null)
                 {
                 {
-                    return await _jsonSerializer.DeserializeFromStreamAsync<IReadOnlyList<PackageInfo>>(stream).ConfigureAwait(false);
+                    return Array.Empty<PackageInfo>();
                 }
                 }
-                catch (SerializationException ex)
+
+                // Store the repository and repository url with each version, as they may be spread apart.
+                foreach (var entry in packages)
                 {
                 {
-                    _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest);
-                    return Array.Empty<PackageInfo>();
+                    foreach (var ver in entry.versions)
+                    {
+                        ver.repositoryName = manifestName;
+                        ver.repositoryUrl = manifest;
+                    }
                 }
                 }
+
+                return packages;
+            }
+            catch (JsonException ex)
+            {
+                _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest);
+                return Array.Empty<PackageInfo>();
             }
             }
             catch (UriFormatException ex)
             catch (UriFormatException ex)
             {
             {
@@ -123,17 +132,75 @@ namespace Emby.Server.Implementations.Updates
             }
             }
         }
         }
 
 
+        private static void MergeSort(IList<VersionInfo> source, IList<VersionInfo> dest)
+        {
+            int sLength = source.Count - 1;
+            int dLength = dest.Count;
+            int s = 0, d = 0;
+            var sourceVersion = source[0].VersionNumber;
+            var destVersion = dest[0].VersionNumber;
+
+            while (d < dLength)
+            {
+                if (sourceVersion.CompareTo(destVersion) >= 0)
+                {
+                    if (s < sLength)
+                    {
+                        sourceVersion = source[++s].VersionNumber;
+                    }
+                    else
+                    {
+                        // Append all of destination to the end of source.
+                        while (d < dLength)
+                        {
+                            source.Add(dest[d++]);
+                        }
+
+                        break;
+                    }
+                }
+                else
+                {
+                    source.Insert(s++, dest[d++]);
+                    if (d >= dLength)
+                    {
+                        break;
+                    }
+
+                    sLength++;
+                    destVersion = dest[d].VersionNumber;
+                }
+            }
+        }
+
         /// <inheritdoc />
         /// <inheritdoc />
         public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default)
         public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default)
         {
         {
             var result = new List<PackageInfo>();
             var result = new List<PackageInfo>();
             foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories)
             foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories)
             {
             {
-                foreach (var package in await GetPackages(repository.Url, cancellationToken).ConfigureAwait(true))
+                if (repository.Enabled)
                 {
                 {
-                    package.repositoryName = repository.Name;
-                    package.repositoryUrl = repository.Url;
-                    result.Add(package);
+                    // Where repositories have the same content, the details of the first is taken.
+                    foreach (var package in await GetPackages(repository.Name, repository.Url, cancellationToken).ConfigureAwait(true))
+                    {
+                        if (!Guid.TryParse(package.guid, out var packageGuid))
+                        {
+                            // Package doesn't have a valid GUID, skip.
+                            continue;
+                        }
+
+                        var existing = FilterPackages(result, package.name, packageGuid).FirstOrDefault();
+                        if (existing != null)
+                        {
+                            // Assumption is both lists are ordered, so slot these into the correct place.
+                            MergeSort(existing.versions, package.versions);
+                        }
+                        else
+                        {
+                            result.Add(package);
+                        }
+                    }
                 }
                 }
             }
             }
 
 
@@ -144,7 +211,8 @@ namespace Emby.Server.Implementations.Updates
         public IEnumerable<PackageInfo> FilterPackages(
         public IEnumerable<PackageInfo> FilterPackages(
             IEnumerable<PackageInfo> availablePackages,
             IEnumerable<PackageInfo> availablePackages,
             string name = null,
             string name = null,
-            Guid guid = default)
+            Guid guid = default,
+            Version specificVersion = null)
         {
         {
             if (name != null)
             if (name != null)
             {
             {
@@ -156,6 +224,11 @@ namespace Emby.Server.Implementations.Updates
                 availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid);
                 availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid);
             }
             }
 
 
+            if (specificVersion != null)
+            {
+                availablePackages = availablePackages.Where(x => x.versions.Where(y => y.VersionNumber.Equals(specificVersion)).Any());
+            }
+
             return availablePackages;
             return availablePackages;
         }
         }
 
 
@@ -167,7 +240,7 @@ namespace Emby.Server.Implementations.Updates
             Version minVersion = null,
             Version minVersion = null,
             Version specificVersion = null)
             Version specificVersion = null)
         {
         {
-            var package = FilterPackages(availablePackages, name, guid).FirstOrDefault();
+            var package = FilterPackages(availablePackages, name, guid, specificVersion).FirstOrDefault();
 
 
             // Package not found in repository
             // Package not found in repository
             if (package == null)
             if (package == null)
@@ -181,21 +254,21 @@ namespace Emby.Server.Implementations.Updates
 
 
             if (specificVersion != null)
             if (specificVersion != null)
             {
             {
-                availableVersions = availableVersions.Where(x => new Version(x.version) == specificVersion);
+                availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion));
             }
             }
             else if (minVersion != null)
             else if (minVersion != null)
             {
             {
-                availableVersions = availableVersions.Where(x => new Version(x.version) >= minVersion);
+                availableVersions = availableVersions.Where(x => x.VersionNumber >= minVersion);
             }
             }
 
 
-            foreach (var v in availableVersions.OrderByDescending(x => x.version))
+            foreach (var v in availableVersions.OrderByDescending(x => x.VersionNumber))
             {
             {
                 yield return new InstallationInfo
                 yield return new InstallationInfo
                 {
                 {
                     Changelog = v.changelog,
                     Changelog = v.changelog,
                     Guid = new Guid(package.guid),
                     Guid = new Guid(package.guid),
                     Name = package.name,
                     Name = package.name,
-                    Version = new Version(v.version),
+                    Version = v.VersionNumber,
                     SourceUrl = v.sourceUrl,
                     SourceUrl = v.sourceUrl,
                     Checksum = v.checksum
                     Checksum = v.checksum
                 };
                 };
@@ -333,7 +406,7 @@ namespace Emby.Server.Implementations.Updates
             string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
             string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
 
 
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
-                .GetAsync(package.SourceUrl, cancellationToken).ConfigureAwait(false);
+                .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 
 
             // CA5351: Do Not Use Broken Cryptographic Algorithms
             // CA5351: Do Not Use Broken Cryptographic Algorithms
@@ -341,7 +414,7 @@ namespace Emby.Server.Implementations.Updates
             using var md5 = MD5.Create();
             using var md5 = MD5.Create();
             cancellationToken.ThrowIfCancellationRequested();
             cancellationToken.ThrowIfCancellationRequested();
 
 
-            var hash = Hex.Encode(md5.ComputeHash(stream));
+            var hash = Convert.ToHexString(md5.ComputeHash(stream));
             if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
             if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
             {
             {
                 _logger.LogError(
                 _logger.LogError(

+ 4 - 1
Jellyfin.Api/Auth/CustomAuthenticationHandler.cs

@@ -18,6 +18,7 @@ namespace Jellyfin.Api.Auth
     public class CustomAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
     public class CustomAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
     {
     {
         private readonly IAuthService _authService;
         private readonly IAuthService _authService;
+        private readonly ILogger<CustomAuthenticationHandler> _logger;
 
 
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="CustomAuthenticationHandler" /> class.
         /// Initializes a new instance of the <see cref="CustomAuthenticationHandler" /> class.
@@ -35,6 +36,7 @@ namespace Jellyfin.Api.Auth
             ISystemClock clock) : base(options, logger, encoder, clock)
             ISystemClock clock) : base(options, logger, encoder, clock)
         {
         {
             _authService = authService;
             _authService = authService;
+            _logger = logger.CreateLogger<CustomAuthenticationHandler>();
         }
         }
 
 
         /// <inheritdoc />
         /// <inheritdoc />
@@ -70,7 +72,8 @@ namespace Jellyfin.Api.Auth
             }
             }
             catch (AuthenticationException ex)
             catch (AuthenticationException ex)
             {
             {
-                return Task.FromResult(AuthenticateResult.Fail(ex));
+                _logger.LogDebug(ex, "Error authenticating with {Handler}", nameof(CustomAuthenticationHandler));
+                return Task.FromResult(AuthenticateResult.NoResult());
             }
             }
             catch (SecurityException ex)
             catch (SecurityException ex)
             {
             {

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Globalization;
 using System.Globalization;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;

+ 63 - 82
Jellyfin.Api/Controllers/ArtistsController.cs

@@ -1,9 +1,8 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
-using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
@@ -87,26 +86,26 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
-            [FromQuery] string? parentId,
+            [FromQuery] Guid? parentId,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
-            [FromQuery] string? genres,
-            [FromQuery] string? genreIds,
-            [FromQuery] string? officialRatings,
-            [FromQuery] string? tags,
-            [FromQuery] string? years,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
             [FromQuery] string? nameStartsWith,
@@ -119,64 +118,55 @@ namespace Jellyfin.Api.Controllers
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 
             User? user = null;
             User? user = null;
-            BaseItem parentItem;
+            BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
 
 
             if (userId.HasValue && !userId.Equals(Guid.Empty))
             if (userId.HasValue && !userId.Equals(Guid.Empty))
             {
             {
                 user = _userManager.GetUserById(userId.Value);
                 user = _userManager.GetUserById(userId.Value);
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
             }
             }
-            else
-            {
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
-            }
-
-            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
-            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
-            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
-                ExcludeItemTypes = excludeItemTypesArr,
-                IncludeItemTypes = includeItemTypesArr,
-                MediaTypes = mediaTypesArr,
+                ExcludeItemTypes = excludeItemTypes,
+                IncludeItemTypes = includeItemTypes,
+                MediaTypes = mediaTypes,
                 StartIndex = startIndex,
                 StartIndex = startIndex,
                 Limit = limit,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, '|', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds),
-                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Tags = tags,
+                OfficialRatings = officialRatings,
+                Genres = genres,
+                GenreIds = genreIds,
+                StudioIds = studioIds,
                 Person = person,
                 Person = person,
-                PersonIds = RequestHelpers.GetGuids(personIds),
-                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
-                Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+                PersonIds = personIds,
+                PersonTypes = personTypes,
+                Years = years,
                 MinCommunityRating = minCommunityRating,
                 MinCommunityRating = minCommunityRating,
                 DtoOptions = dtoOptions,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
                 SearchTerm = searchTerm,
                 EnableTotalRecordCount = enableTotalRecordCount
                 EnableTotalRecordCount = enableTotalRecordCount
             };
             };
 
 
-            if (!string.IsNullOrWhiteSpace(parentId))
+            if (parentId.HasValue)
             {
             {
                 if (parentItem is Folder)
                 if (parentItem is Folder)
                 {
                 {
-                    query.AncestorIds = new[] { new Guid(parentId) };
+                    query.AncestorIds = new[] { parentId.Value };
                 }
                 }
                 else
                 else
                 {
                 {
-                    query.ItemIds = new[] { new Guid(parentId) };
+                    query.ItemIds = new[] { parentId.Value };
                 }
                 }
             }
             }
 
 
             // Studios
             // Studios
-            if (!string.IsNullOrEmpty(studios))
+            if (studios.Length != 0)
             {
             {
-                query.StudioIds = studios.Split('|').Select(i =>
+                query.StudioIds = studios.Select(i =>
                 {
                 {
                     try
                     try
                     {
                     {
@@ -230,7 +220,7 @@ namespace Jellyfin.Api.Controllers
                 var (baseItem, itemCounts) = i;
                 var (baseItem, itemCounts) = i;
                 var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
                 var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
 
 
-                if (!string.IsNullOrWhiteSpace(includeItemTypes))
+                if (includeItemTypes.Length != 0)
                 {
                 {
                     dto.ChildCount = itemCounts.ItemCount;
                     dto.ChildCount = itemCounts.ItemCount;
                     dto.ProgramCount = itemCounts.ProgramCount;
                     dto.ProgramCount = itemCounts.ProgramCount;
@@ -295,26 +285,26 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
-            [FromQuery] string? parentId,
+            [FromQuery] Guid? parentId,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
-            [FromQuery] string? genres,
-            [FromQuery] string? genreIds,
-            [FromQuery] string? officialRatings,
-            [FromQuery] string? tags,
-            [FromQuery] string? years,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
             [FromQuery] string? nameStartsWith,
@@ -327,64 +317,55 @@ namespace Jellyfin.Api.Controllers
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 
             User? user = null;
             User? user = null;
-            BaseItem parentItem;
+            BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
 
 
             if (userId.HasValue && !userId.Equals(Guid.Empty))
             if (userId.HasValue && !userId.Equals(Guid.Empty))
             {
             {
                 user = _userManager.GetUserById(userId.Value);
                 user = _userManager.GetUserById(userId.Value);
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
             }
             }
-            else
-            {
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
-            }
-
-            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
-            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
-            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
-                ExcludeItemTypes = excludeItemTypesArr,
-                IncludeItemTypes = includeItemTypesArr,
-                MediaTypes = mediaTypesArr,
+                ExcludeItemTypes = excludeItemTypes,
+                IncludeItemTypes = includeItemTypes,
+                MediaTypes = mediaTypes,
                 StartIndex = startIndex,
                 StartIndex = startIndex,
                 Limit = limit,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, '|', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds),
-                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Tags = tags,
+                OfficialRatings = officialRatings,
+                Genres = genres,
+                GenreIds = genreIds,
+                StudioIds = studioIds,
                 Person = person,
                 Person = person,
-                PersonIds = RequestHelpers.GetGuids(personIds),
-                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
-                Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+                PersonIds = personIds,
+                PersonTypes = personTypes,
+                Years = years,
                 MinCommunityRating = minCommunityRating,
                 MinCommunityRating = minCommunityRating,
                 DtoOptions = dtoOptions,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
                 SearchTerm = searchTerm,
                 EnableTotalRecordCount = enableTotalRecordCount
                 EnableTotalRecordCount = enableTotalRecordCount
             };
             };
 
 
-            if (!string.IsNullOrWhiteSpace(parentId))
+            if (parentId.HasValue)
             {
             {
                 if (parentItem is Folder)
                 if (parentItem is Folder)
                 {
                 {
-                    query.AncestorIds = new[] { new Guid(parentId) };
+                    query.AncestorIds = new[] { parentId.Value };
                 }
                 }
                 else
                 else
                 {
                 {
-                    query.ItemIds = new[] { new Guid(parentId) };
+                    query.ItemIds = new[] { parentId.Value };
                 }
                 }
             }
             }
 
 
             // Studios
             // Studios
-            if (!string.IsNullOrEmpty(studios))
+            if (studios.Length != 0)
             {
             {
-                query.StudioIds = studios.Split('|').Select(i =>
+                query.StudioIds = studios.Select(i =>
                 {
                 {
                     try
                     try
                     {
                     {
@@ -438,7 +419,7 @@ namespace Jellyfin.Api.Controllers
                 var (baseItem, itemCounts) = i;
                 var (baseItem, itemCounts) = i;
                 var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
                 var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
 
 
-                if (!string.IsNullOrWhiteSpace(includeItemTypes))
+                if (includeItemTypes.Length != 0)
                 {
                 {
                     dto.ChildCount = itemCounts.ItemCount;
                     dto.ChildCount = itemCounts.ItemCount;
                     dto.ProgramCount = itemCounts.ProgramCount;
                     dto.ProgramCount = itemCounts.ProgramCount;

+ 169 - 6
Jellyfin.Api/Controllers/AudioController.cs

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

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

@@ -1,4 +1,4 @@
-using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Model.Branding;
 using MediaBrowser.Model.Branding;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;

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

@@ -92,7 +92,7 @@ namespace Jellyfin.Api.Controllers
         /// <response code="200">Channel features returned.</response>
         /// <response code="200">Channel features returned.</response>
         /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
         /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
         [HttpGet("{channelId}/Features")]
         [HttpGet("{channelId}/Features")]
-        public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] string channelId)
+        public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId)
         {
         {
             return _channelManager.GetChannelFeatures(channelId);
             return _channelManager.GetChannelFeatures(channelId);
         }
         }
@@ -198,7 +198,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? channelIds)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds)
         {
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
@@ -208,11 +208,7 @@ namespace Jellyfin.Api.Controllers
             {
             {
                 Limit = limit,
                 Limit = limit,
                 StartIndex = startIndex,
                 StartIndex = startIndex,
-                ChannelIds = (channelIds ?? string.Empty)
-                    .Split(',')
-                    .Where(i => !string.IsNullOrWhiteSpace(i))
-                    .Select(i => new Guid(i))
-                    .ToArray(),
+                ChannelIds = channelIds,
                 DtoOptions = new DtoOptions { Fields = fields }
                 DtoOptions = new DtoOptions { Fields = fields }
             };
             };
 
 

+ 12 - 7
Jellyfin.Api/Controllers/CollectionController.cs

@@ -1,9 +1,10 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using MediaBrowser.Controller.Collections;
 using MediaBrowser.Controller.Collections;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
@@ -54,7 +55,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public async Task<ActionResult<CollectionCreationResult>> CreateCollection(
         public async Task<ActionResult<CollectionCreationResult>> CreateCollection(
             [FromQuery] string? name,
             [FromQuery] string? name,
-            [FromQuery] string? ids,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids,
             [FromQuery] Guid? parentId,
             [FromQuery] Guid? parentId,
             [FromQuery] bool isLocked = false)
             [FromQuery] bool isLocked = false)
         {
         {
@@ -65,7 +66,7 @@ namespace Jellyfin.Api.Controllers
                 IsLocked = isLocked,
                 IsLocked = isLocked,
                 Name = name,
                 Name = name,
                 ParentId = parentId,
                 ParentId = parentId,
-                ItemIdList = RequestHelpers.Split(ids, ',', true),
+                ItemIdList = ids,
                 UserIds = new[] { userId }
                 UserIds = new[] { userId }
             }).ConfigureAwait(false);
             }).ConfigureAwait(false);
 
 
@@ -88,9 +89,11 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("{collectionId}/Items")]
         [HttpPost("{collectionId}/Items")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids)
+        public async Task<ActionResult> AddToCollection(
+            [FromRoute, Required] Guid collectionId,
+            [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
         {
         {
-            await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(true);
+            await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
             return NoContent();
             return NoContent();
         }
         }
 
 
@@ -103,9 +106,11 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpDelete("{collectionId}/Items")]
         [HttpDelete("{collectionId}/Items")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids)
+        public async Task<ActionResult> RemoveFromCollection(
+            [FromRoute, Required] Guid collectionId,
+            [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
         {
         {
-            await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(false);
+            await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false);
             return NoContent();
             return NoContent();
         }
         }
     }
     }

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;

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

@@ -252,7 +252,7 @@ namespace Jellyfin.Api.Controllers
 
 
         private string GetAbsoluteUri()
         private string GetAbsoluteUri()
         {
         {
-            return $"{Request.Scheme}://{Request.Host}{Request.Path}";
+            return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}";
         }
         }
 
 
         private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service)
         private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service)

+ 20 - 20
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -160,7 +160,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
@@ -216,7 +216,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] string? videoCodec,
             [FromQuery] string? videoCodec,
             [FromQuery] string? subtitleCodec,
             [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodingReasons,
+            [FromQuery] string? transcodeReasons,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] EncodingContext context,
             [FromQuery] EncodingContext context,
@@ -268,7 +268,7 @@ namespace Jellyfin.Api.Controllers
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 VideoCodec = videoCodec,
                 VideoCodec = videoCodec,
                 SubtitleCodec = subtitleCodec,
                 SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodingReasons,
+                TranscodeReasons = transcodeReasons,
                 AudioStreamIndex = audioStreamIndex,
                 AudioStreamIndex = audioStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 Context = context,
                 Context = context,
@@ -326,7 +326,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
@@ -383,7 +383,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] string? videoCodec,
             [FromQuery] string? videoCodec,
             [FromQuery] string? subtitleCodec,
             [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodingReasons,
+            [FromQuery] string? transcodeReasons,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] EncodingContext context,
             [FromQuery] EncodingContext context,
@@ -435,7 +435,7 @@ namespace Jellyfin.Api.Controllers
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 VideoCodec = videoCodec,
                 VideoCodec = videoCodec,
                 SubtitleCodec = subtitleCodec,
                 SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodingReasons,
+                TranscodeReasons = transcodeReasons,
                 AudioStreamIndex = audioStreamIndex,
                 AudioStreamIndex = audioStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 Context = context,
                 Context = context,
@@ -492,7 +492,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
@@ -546,7 +546,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] string? videoCodec,
             [FromQuery] string? videoCodec,
             [FromQuery] string? subtitleCodec,
             [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodingReasons,
+            [FromQuery] string? transcodeReasons,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] EncodingContext context,
             [FromQuery] EncodingContext context,
@@ -598,7 +598,7 @@ namespace Jellyfin.Api.Controllers
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 VideoCodec = videoCodec,
                 VideoCodec = videoCodec,
                 SubtitleCodec = subtitleCodec,
                 SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodingReasons,
+                TranscodeReasons = transcodeReasons,
                 AudioStreamIndex = audioStreamIndex,
                 AudioStreamIndex = audioStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 Context = context,
                 Context = context,
@@ -656,7 +656,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
@@ -711,7 +711,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] string? videoCodec,
             [FromQuery] string? videoCodec,
             [FromQuery] string? subtitleCodec,
             [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodingReasons,
+            [FromQuery] string? transcodeReasons,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] EncodingContext context,
             [FromQuery] EncodingContext context,
@@ -763,7 +763,7 @@ namespace Jellyfin.Api.Controllers
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 VideoCodec = videoCodec,
                 VideoCodec = videoCodec,
                 SubtitleCodec = subtitleCodec,
                 SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodingReasons,
+                TranscodeReasons = transcodeReasons,
                 AudioStreamIndex = audioStreamIndex,
                 AudioStreamIndex = audioStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 Context = context,
                 Context = context,
@@ -823,7 +823,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
@@ -838,7 +838,7 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] string playlistId,
             [FromRoute, Required] string playlistId,
             [FromRoute, Required] int segmentId,
             [FromRoute, Required] int segmentId,
-            [FromRoute] string container,
+            [FromRoute, Required] string container,
             [FromQuery] bool? @static,
             [FromQuery] bool? @static,
             [FromQuery] string? @params,
             [FromQuery] string? @params,
             [FromQuery] string? tag,
             [FromQuery] string? tag,
@@ -881,7 +881,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] string? videoCodec,
             [FromQuery] string? videoCodec,
             [FromQuery] string? subtitleCodec,
             [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodingReasons,
+            [FromQuery] string? transcodeReasons,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] EncodingContext context,
             [FromQuery] EncodingContext context,
@@ -933,7 +933,7 @@ namespace Jellyfin.Api.Controllers
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 VideoCodec = videoCodec,
                 VideoCodec = videoCodec,
                 SubtitleCodec = subtitleCodec,
                 SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodingReasons,
+                TranscodeReasons = transcodeReasons,
                 AudioStreamIndex = audioStreamIndex,
                 AudioStreamIndex = audioStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 Context = context,
                 Context = context,
@@ -994,7 +994,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
         /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
         /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
@@ -1009,7 +1009,7 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] string playlistId,
             [FromRoute, Required] string playlistId,
             [FromRoute, Required] int segmentId,
             [FromRoute, Required] int segmentId,
-            [FromRoute] string container,
+            [FromRoute, Required] string container,
             [FromQuery] bool? @static,
             [FromQuery] bool? @static,
             [FromQuery] string? @params,
             [FromQuery] string? @params,
             [FromQuery] string? tag,
             [FromQuery] string? tag,
@@ -1053,7 +1053,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] bool? enableMpegtsM2TsMode,
             [FromQuery] string? videoCodec,
             [FromQuery] string? videoCodec,
             [FromQuery] string? subtitleCodec,
             [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodingReasons,
+            [FromQuery] string? transcodeReasons,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? audioStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] int? videoStreamIndex,
             [FromQuery] EncodingContext context,
             [FromQuery] EncodingContext context,
@@ -1105,7 +1105,7 @@ namespace Jellyfin.Api.Controllers
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
                 VideoCodec = videoCodec,
                 VideoCodec = videoCodec,
                 SubtitleCodec = subtitleCodec,
                 SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodingReasons,
+                TranscodeReasons = transcodeReasons,
                 AudioStreamIndex = audioStreamIndex,
                 AudioStreamIndex = audioStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 VideoStreamIndex = videoStreamIndex,
                 Context = context,
                 Context = context,

+ 38 - 38
Jellyfin.Api/Controllers/FilterController.cs

@@ -1,6 +1,7 @@
-using System;
+using System;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
+using Jellyfin.Api.ModelBinders;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Entities.Audio;
@@ -49,37 +50,29 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
         public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
-            [FromQuery] string? parentId,
-            [FromQuery] string? includeItemTypes,
-            [FromQuery] string? mediaTypes)
+            [FromQuery] Guid? parentId,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes)
         {
         {
-            var parentItem = string.IsNullOrEmpty(parentId)
-                ? null
-                : _libraryManager.GetItemById(parentId);
-
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
 
 
-            if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+            BaseItem? item = null;
+            if (includeItemTypes.Length != 1
+                || !(string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+                     || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+                     || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+                     || string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase)))
             {
             {
-                parentItem = null;
+                item = _libraryManager.GetParentItem(parentId, user?.Id);
             }
             }
 
 
-            var item = string.IsNullOrEmpty(parentId)
-                ? user == null
-                    ? _libraryManager.RootFolder
-                    : _libraryManager.GetUserRootFolder()
-                : parentItem;
-
             var query = new InternalItemsQuery
             var query = new InternalItemsQuery
             {
             {
                 User = user,
                 User = user,
-                MediaTypes = (mediaTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
-                IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
+                MediaTypes = mediaTypes,
+                IncludeItemTypes = includeItemTypes,
                 Recursive = true,
                 Recursive = true,
                 EnableTotalRecordCount = false,
                 EnableTotalRecordCount = false,
                 DtoOptions = new DtoOptions
                 DtoOptions = new DtoOptions
@@ -90,7 +83,12 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
             };
             };
 
 
-            var itemList = ((Folder)item!).GetItemList(query);
+            if (item is not Folder folder)
+            {
+                return new QueryFiltersLegacy();
+            }
+
+            var itemList = folder.GetItemList(query);
             return new QueryFiltersLegacy
             return new QueryFiltersLegacy
             {
             {
                 Years = itemList.Select(i => i.ProductionYear ?? -1)
                 Years = itemList.Select(i => i.ProductionYear ?? -1)
@@ -138,8 +136,8 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryFilters> GetQueryFilters(
         public ActionResult<QueryFilters> GetQueryFilters(
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
-            [FromQuery] string? parentId,
-            [FromQuery] string? includeItemTypes,
+            [FromQuery] Guid? parentId,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
             [FromQuery] bool? isAiring,
             [FromQuery] bool? isAiring,
             [FromQuery] bool? isMovie,
             [FromQuery] bool? isMovie,
             [FromQuery] bool? isSports,
             [FromQuery] bool? isSports,
@@ -148,27 +146,28 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isSeries,
             [FromQuery] bool? isSeries,
             [FromQuery] bool? recursive)
             [FromQuery] bool? recursive)
         {
         {
-            var parentItem = string.IsNullOrEmpty(parentId)
-                ? null
-                : _libraryManager.GetItemById(parentId);
-
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
 
 
-            if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+            BaseItem? parentItem = null;
+            if (includeItemTypes.Length == 1
+                && (string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase)))
             {
             {
                 parentItem = null;
                 parentItem = null;
             }
             }
+            else if (parentId.HasValue)
+            {
+                parentItem = _libraryManager.GetItemById(parentId.Value);
+            }
 
 
             var filters = new QueryFilters();
             var filters = new QueryFilters();
             var genreQuery = new InternalItemsQuery(user)
             var genreQuery = new InternalItemsQuery(user)
             {
             {
-                IncludeItemTypes =
-                    (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
+                IncludeItemTypes = includeItemTypes,
                 DtoOptions = new DtoOptions
                 DtoOptions = new DtoOptions
                 {
                 {
                     Fields = Array.Empty<ItemFields>(),
                     Fields = Array.Empty<ItemFields>(),
@@ -192,10 +191,11 @@ namespace Jellyfin.Api.Controllers
                 genreQuery.Parent = parentItem;
                 genreQuery.Parent = parentItem;
             }
             }
 
 
-            if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase))
+            if (includeItemTypes.Length == 1
+                && (string.Equals(includeItemTypes[0], nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(includeItemTypes[0], nameof(MusicVideo), StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(includeItemTypes[0], nameof(MusicArtist), StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(includeItemTypes[0], nameof(Audio), StringComparison.OrdinalIgnoreCase)))
             {
             {
                 filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
                 filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
                 {
                 {

+ 10 - 10
Jellyfin.Api/Controllers/GenresController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
@@ -72,10 +72,10 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
-            [FromQuery] string? parentId,
+            [FromQuery] Guid? parentId,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
             [FromQuery] bool? isFavorite,
             [FromQuery] bool? isFavorite,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
-                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
-                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                ExcludeItemTypes = excludeItemTypes,
+                IncludeItemTypes = includeItemTypes,
                 StartIndex = startIndex,
                 StartIndex = startIndex,
                 Limit = limit,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 IsFavorite = isFavorite,
@@ -109,15 +109,15 @@ namespace Jellyfin.Api.Controllers
                 EnableTotalRecordCount = enableTotalRecordCount
                 EnableTotalRecordCount = enableTotalRecordCount
             };
             };
 
 
-            if (!string.IsNullOrWhiteSpace(parentId))
+            if (parentId.HasValue)
             {
             {
                 if (parentItem is Folder)
                 if (parentItem is Folder)
                 {
                 {
-                    query.AncestorIds = new[] { new Guid(parentId) };
+                    query.AncestorIds = new[] { parentId.Value };
                 }
                 }
                 else
                 else
                 {
                 {
-                    query.ItemIds = new[] { new Guid(parentId) };
+                    query.ItemIds = new[] { parentId.Value };
                 }
                 }
             }
             }
 
 
@@ -133,7 +133,7 @@ namespace Jellyfin.Api.Controllers
                 result = _libraryManager.GetGenres(query);
                 result = _libraryManager.GetGenres(query);
             }
             }
 
 
-            var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
+            var shouldIncludeItemTypes = includeItemTypes.Length != 0;
             return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
             return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
         }
 
 

+ 21 - 7
Jellyfin.Api/Controllers/HlsSegmentController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Diagnostics.CodeAnalysis;
 using System.Diagnostics.CodeAnalysis;
 using System.IO;
 using System.IO;
@@ -112,11 +112,13 @@ namespace Jellyfin.Api.Controllers
         /// <param name="segmentId">The segment id.</param>
         /// <param name="segmentId">The segment id.</param>
         /// <param name="segmentContainer">The segment container.</param>
         /// <param name="segmentContainer">The segment container.</param>
         /// <response code="200">Hls video segment returned.</response>
         /// <response code="200">Hls video segment returned.</response>
+        /// <response code="404">Hls segment not found.</response>
         /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns>
         /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns>
         // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
         // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
         // [Authenticated]
         // [Authenticated]
         [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
         [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesVideoFile]
         [ProducesVideoFile]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
         public ActionResult GetHlsVideoSegmentLegacy(
         public ActionResult GetHlsVideoSegmentLegacy(
@@ -132,13 +134,25 @@ namespace Jellyfin.Api.Controllers
 
 
             var normalizedPlaylistId = playlistId;
             var normalizedPlaylistId = playlistId;
 
 
-            var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath)
-                .FirstOrDefault(i =>
-                    string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase)
-                    && i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1)
-                ?? throw new ResourceNotFoundException($"Provided path ({transcodeFolderPath}) is not valid.");
+            var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
+            // Add . to start of segment container for future use.
+            segmentContainer = segmentContainer.Insert(0, ".");
+            string? playlistPath = null;
+            foreach (var path in filePaths)
+            {
+                var pathExtension = Path.GetExtension(path);
+                if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
+                     || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
+                    && path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1)
+                {
+                    playlistPath = path;
+                    break;
+                }
+            }
 
 
-            return GetFileResult(file, playlistPath);
+            return playlistPath == null
+                ? NotFound("Hls segment not found.")
+                : GetFileResult(file, playlistPath);
         }
         }
 
 
         private ActionResult GetFileResult(string path, string playlistPath)
         private ActionResult GetFileResult(string path, string playlistPath)

+ 686 - 56
Jellyfin.Api/Controllers/ImageController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Diagnostics.CodeAnalysis;
 using System.Diagnostics.CodeAnalysis;
@@ -86,7 +86,6 @@ namespace Jellyfin.Api.Controllers
         /// <response code="403">User does not have permission to delete the image.</response>
         /// <response code="403">User does not have permission to delete the image.</response>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
         [HttpPost("Users/{userId}/Images/{imageType}")]
         [HttpPost("Users/{userId}/Images/{imageType}")]
-        [HttpPost("Users/{userId}/Images/{imageType}/{index?}", Name = "PostUserImage_2")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
@@ -95,7 +94,53 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult> PostUserImage(
         public async Task<ActionResult> PostUserImage(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] ImageType imageType,
             [FromRoute, Required] ImageType imageType,
-            [FromRoute] int? index = null)
+            [FromQuery] int? index = null)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+            {
+                return Forbid("User is not allowed to update the image.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+            await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType.Split(';').FirstOrDefault();
+            var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+            if (user.ProfileImage != null)
+            {
+                await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+            }
+
+            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType)));
+
+            await _providerManager
+                .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+                .ConfigureAwait(false);
+            await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Sets the user image.
+        /// </summary>
+        /// <param name="userId">User Id.</param>
+        /// <param name="imageType">(Unused) Image type.</param>
+        /// <param name="index">(Unused) Image index.</param>
+        /// <response code="204">Image updated.</response>
+        /// <response code="403">User does not have permission to delete the image.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("Users/{userId}/Images/{imageType}/{index}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status403Forbidden)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+        public async Task<ActionResult> PostUserImageByIndex(
+            [FromRoute, Required] Guid userId,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute] int index)
         {
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             {
             {
@@ -132,8 +177,7 @@ namespace Jellyfin.Api.Controllers
         /// <response code="204">Image deleted.</response>
         /// <response code="204">Image deleted.</response>
         /// <response code="403">User does not have permission to delete the image.</response>
         /// <response code="403">User does not have permission to delete the image.</response>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Users/{userId}/Images/{itemType}")]
-        [HttpDelete("Users/{userId}/Images/{itemType}/{index?}", Name = "DeleteUserImage_2")]
+        [HttpDelete("Users/{userId}/Images/{imageType}")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
@@ -142,7 +186,46 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult> DeleteUserImage(
         public async Task<ActionResult> DeleteUserImage(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] ImageType imageType,
             [FromRoute, Required] ImageType imageType,
-            [FromRoute] int? index = null)
+            [FromQuery] int? index = null)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+            {
+                return Forbid("User is not allowed to delete the image.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+            try
+            {
+                System.IO.File.Delete(user.ProfileImage.Path);
+            }
+            catch (IOException e)
+            {
+                _logger.LogError(e, "Error deleting user profile image:");
+            }
+
+            await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Delete the user's image.
+        /// </summary>
+        /// <param name="userId">User Id.</param>
+        /// <param name="imageType">(Unused) Image type.</param>
+        /// <param name="index">(Unused) Image index.</param>
+        /// <response code="204">Image deleted.</response>
+        /// <response code="403">User does not have permission to delete the image.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpDelete("Users/{userId}/Images/{imageType}/{index}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status403Forbidden)]
+        public async Task<ActionResult> DeleteUserImageByIndex(
+            [FromRoute, Required] Guid userId,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute] int index)
         {
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             {
             {
@@ -173,14 +256,13 @@ namespace Jellyfin.Api.Controllers
         /// <response code="404">Item not found.</response>
         /// <response code="404">Item not found.</response>
         /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
         /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
         [HttpDelete("Items/{itemId}/Images/{imageType}")]
         [HttpDelete("Items/{itemId}/Images/{imageType}")]
-        [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "DeleteItemImage_2")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult> DeleteItemImage(
         public async Task<ActionResult> DeleteItemImage(
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] ImageType imageType,
             [FromRoute, Required] ImageType imageType,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
         {
         {
             var item = _libraryManager.GetItemById(itemId);
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)
             if (item == null)
@@ -192,25 +274,83 @@ namespace Jellyfin.Api.Controllers
             return NoContent();
             return NoContent();
         }
         }
 
 
+        /// <summary>
+        /// Delete an item's image.
+        /// </summary>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">The image index.</param>
+        /// <response code="204">Image deleted.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+        [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> DeleteItemImageByIndex(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute] int imageIndex)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false);
+            return NoContent();
+        }
+
         /// <summary>
         /// <summary>
         /// Set item image.
         /// Set item image.
         /// </summary>
         /// </summary>
         /// <param name="itemId">Item id.</param>
         /// <param name="itemId">Item id.</param>
         /// <param name="imageType">Image type.</param>
         /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">(Unused) Image index.</param>
         /// <response code="204">Image saved.</response>
         /// <response code="204">Image saved.</response>
         /// <response code="404">Item not found.</response>
         /// <response code="404">Item not found.</response>
         /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
         /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
         [HttpPost("Items/{itemId}/Images/{imageType}")]
         [HttpPost("Items/{itemId}/Images/{imageType}")]
-        [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "SetItemImage_2")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
         public async Task<ActionResult> SetItemImage(
         public async Task<ActionResult> SetItemImage(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] ImageType imageType)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType.Split(';').FirstOrDefault();
+            await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+            await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Set item image.
+        /// </summary>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">(Unused) Image index.</param>
+        /// <response code="204">Image saved.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+        [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+        public async Task<ActionResult> SetItemImageByIndex(
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] ImageType imageType,
             [FromRoute, Required] ImageType imageType,
-            [FromRoute] int? imageIndex = null)
+            [FromRoute] int imageIndex)
         {
         {
             var item = _libraryManager.GetItemById(itemId);
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)
             if (item == null)
@@ -350,8 +490,6 @@ namespace Jellyfin.Api.Controllers
         /// </returns>
         /// </returns>
         [HttpGet("Items/{itemId}/Images/{imageType}")]
         [HttpGet("Items/{itemId}/Images/{imageType}")]
         [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")]
         [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")]
-        [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "GetItemImage_2")]
-        [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
         [ProducesImageFile]
@@ -372,7 +510,86 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? foregroundLayer,
             [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    itemId,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Gets the item's image.
+        /// </summary>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetItemImageByIndex(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute] int imageIndex,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] string? tag,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer)
         {
         {
             var item = _libraryManager.GetItemById(itemId);
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)
             if (item == null)
@@ -508,8 +725,8 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
         /// </returns>
-        [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadArtistImage")]
+        [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
         [ProducesImageFile]
@@ -587,8 +804,8 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
         /// </returns>
-        [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadGenreImage")]
+        [HttpGet("Genres/{name}/Images/{imageType}")]
+        [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
         [ProducesImageFile]
@@ -609,7 +826,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? foregroundLayer,
             [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
         {
         {
             var item = _libraryManager.GetGenre(name);
             var item = _libraryManager.GetGenre(name);
             if (item == null)
             if (item == null)
@@ -641,10 +858,11 @@ namespace Jellyfin.Api.Controllers
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Get music genre image by name.
+        /// Get genre image by name.
         /// </summary>
         /// </summary>
-        /// <param name="name">Music genre name.</param>
+        /// <param name="name">Genre name.</param>
         /// <param name="imageType">Image type.</param>
         /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
         /// <param name="maxWidth">The maximum image width to return.</param>
         /// <param name="maxWidth">The maximum image width to return.</param>
@@ -659,21 +877,21 @@ namespace Jellyfin.Api.Controllers
         /// <param name="blur">Optional. Blur image.</param>
         /// <param name="blur">Optional. Blur image.</param>
         /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
         /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
         /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
         /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
         /// <response code="200">Image stream returned.</response>
         /// <response code="200">Image stream returned.</response>
         /// <response code="404">Item not found.</response>
         /// <response code="404">Item not found.</response>
         /// <returns>
         /// <returns>
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
         /// </returns>
-        [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadMusicGenreImage")]
+        [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
         [ProducesImageFile]
-        public async Task<ActionResult> GetMusicGenreImage(
+        public async Task<ActionResult> GetGenreImageByIndex(
             [FromRoute, Required] string name,
             [FromRoute, Required] string name,
             [FromRoute, Required] ImageType imageType,
             [FromRoute, Required] ImageType imageType,
+            [FromRoute, Required] int imageIndex,
             [FromQuery] string tag,
             [FromQuery] string tag,
             [FromQuery] ImageFormat? format,
             [FromQuery] ImageFormat? format,
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxWidth,
@@ -687,10 +905,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? addPlayedIndicator,
             [FromQuery] bool? addPlayedIndicator,
             [FromQuery] int? blur,
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] string? foregroundLayer)
         {
         {
-            var item = _libraryManager.GetMusicGenre(name);
+            var item = _libraryManager.GetGenre(name);
             if (item == null)
             if (item == null)
             {
             {
                 return NotFound();
                 return NotFound();
@@ -720,9 +937,9 @@ namespace Jellyfin.Api.Controllers
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Get person image by name.
+        /// Get music genre image by name.
         /// </summary>
         /// </summary>
-        /// <param name="name">Person name.</param>
+        /// <param name="name">Music genre name.</param>
         /// <param name="imageType">Image type.</param>
         /// <param name="imageType">Image type.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
@@ -745,12 +962,12 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
         /// </returns>
-        [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadPersonImage")]
+        [HttpGet("MusicGenres/{name}/Images/{imageType}")]
+        [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
         [ProducesImageFile]
-        public async Task<ActionResult> GetPersonImage(
+        public async Task<ActionResult> GetMusicGenreImage(
             [FromRoute, Required] string name,
             [FromRoute, Required] string name,
             [FromRoute, Required] ImageType imageType,
             [FromRoute, Required] ImageType imageType,
             [FromQuery] string tag,
             [FromQuery] string tag,
@@ -767,9 +984,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? foregroundLayer,
             [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
         {
         {
-            var item = _libraryManager.GetPerson(name);
+            var item = _libraryManager.GetMusicGenre(name);
             if (item == null)
             if (item == null)
             {
             {
                 return NotFound();
                 return NotFound();
@@ -799,10 +1016,11 @@ namespace Jellyfin.Api.Controllers
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Get studio image by name.
+        /// Get music genre image by name.
         /// </summary>
         /// </summary>
-        /// <param name="name">Studio name.</param>
+        /// <param name="name">Music genre name.</param>
         /// <param name="imageType">Image type.</param>
         /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
         /// <param name="maxWidth">The maximum image width to return.</param>
         /// <param name="maxWidth">The maximum image width to return.</param>
@@ -817,23 +1035,23 @@ namespace Jellyfin.Api.Controllers
         /// <param name="blur">Optional. Blur image.</param>
         /// <param name="blur">Optional. Blur image.</param>
         /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
         /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
         /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
         /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
         /// <response code="200">Image stream returned.</response>
         /// <response code="200">Image stream returned.</response>
         /// <response code="404">Item not found.</response>
         /// <response code="404">Item not found.</response>
         /// <returns>
         /// <returns>
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
         /// </returns>
-        [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadStudioImage")]
+        [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
         [ProducesImageFile]
-        public async Task<ActionResult> GetStudioImage(
+        public async Task<ActionResult> GetMusicGenreImageByIndex(
             [FromRoute, Required] string name,
             [FromRoute, Required] string name,
             [FromRoute, Required] ImageType imageType,
             [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] string tag,
-            [FromRoute, Required] ImageFormat format,
+            [FromRoute, Required] int imageIndex,
+            [FromQuery] string tag,
+            [FromQuery] ImageFormat? format,
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxHeight,
             [FromQuery] int? maxHeight,
             [FromQuery] double? percentPlayed,
             [FromQuery] double? percentPlayed,
@@ -845,10 +1063,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? addPlayedIndicator,
             [FromQuery] bool? addPlayedIndicator,
             [FromQuery] int? blur,
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] string? foregroundLayer)
         {
         {
-            var item = _libraryManager.GetStudio(name);
+            var item = _libraryManager.GetMusicGenre(name);
             if (item == null)
             if (item == null)
             {
             {
                 return NotFound();
                 return NotFound();
@@ -878,9 +1095,9 @@ namespace Jellyfin.Api.Controllers
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Get user profile image.
+        /// Get person image by name.
         /// </summary>
         /// </summary>
-        /// <param name="userId">User id.</param>
+        /// <param name="name">Person name.</param>
         /// <param name="imageType">Image type.</param>
         /// <param name="imageType">Image type.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
@@ -903,15 +1120,15 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
         /// </returns>
-        [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex?}", Name = "HeadUserImage")]
+        [HttpGet("Persons/{name}/Images/{imageType}")]
+        [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
         [ProducesImageFile]
-        public async Task<ActionResult> GetUserImage(
-            [FromRoute, Required] Guid userId,
+        public async Task<ActionResult> GetPersonImage(
+            [FromRoute, Required] string name,
             [FromRoute, Required] ImageType imageType,
             [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
+            [FromQuery] string tag,
             [FromQuery] ImageFormat? format,
             [FromQuery] ImageFormat? format,
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxHeight,
             [FromQuery] int? maxHeight,
@@ -925,10 +1142,423 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? foregroundLayer,
             [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
         {
         {
-            var user = _userManager.GetUserById(userId);
-            if (user == null)
+            var item = _libraryManager.GetPerson(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Get person image by name.
+        /// </summary>
+        /// <param name="name">Person name.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetPersonImageByIndex(
+            [FromRoute, Required] string name,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute, Required] int imageIndex,
+            [FromQuery] string tag,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer)
+        {
+            var item = _libraryManager.GetPerson(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Get studio image by name.
+        /// </summary>
+        /// <param name="name">Studio name.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Studios/{name}/Images/{imageType}")]
+        [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetStudioImage(
+            [FromRoute, Required] string name,
+            [FromRoute, Required] ImageType imageType,
+            [FromQuery] string? tag,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromQuery] int? imageIndex)
+        {
+            var item = _libraryManager.GetStudio(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Get studio image by name.
+        /// </summary>
+        /// <param name="name">Studio name.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetStudioImageByIndex(
+            [FromRoute, Required] string name,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute, Required] int imageIndex,
+            [FromQuery] string? tag,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer)
+        {
+            var item = _libraryManager.GetStudio(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Get user profile image.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Users/{userId}/Images/{imageType}")]
+        [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetUserImage(
+            [FromRoute, Required] Guid userId,
+            [FromRoute, Required] ImageType imageType,
+            [FromQuery] string? tag,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromQuery] int? imageIndex)
+        {
+            var user = _userManager.GetUserById(userId);
+            if (user == null)
+            {
+                return NotFound();
+            }
+
+            var info = new ItemImageInfo
+            {
+                Path = user.ProfileImage.Path,
+                Type = ImageType.Profile,
+                DateModified = user.ProfileImage.LastModified
+            };
+
+            if (width.HasValue)
+            {
+                info.Width = width.Value;
+            }
+
+            if (height.HasValue)
+            {
+                info.Height = height.Value;
+            }
+
+            return await GetImageInternal(
+                    user.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    null,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase),
+                    info)
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Get user profile image.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetUserImageByIndex(
+            [FromRoute, Required] Guid userId,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute, Required] int imageIndex,
+            [FromQuery] string? tag,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer)
+        {
+            var user = _userManager.GetUserById(userId);
+            if (user?.ProfileImage == null)
             {
             {
                 return NotFound();
                 return NotFound();
             }
             }

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;
@@ -206,7 +206,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
         /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
         /// <response code="200">Instant playlist returned.</response>
         /// <response code="200">Instant playlist returned.</response>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Artists/InstantMix")]
+        [HttpGet("Artists/{id}/InstantMix")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
         public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
             [FromRoute, Required] Guid id,
             [FromRoute, Required] Guid id,
@@ -242,7 +242,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
         /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
         /// <response code="200">Instant playlist returned.</response>
         /// <response code="200">Instant playlist returned.</response>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("MusicGenres/InstantMix")]
+        [HttpGet("MusicGenres/{id}/InstantMix")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres(
         public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres(
             [FromRoute, Required] Guid id,
             [FromRoute, Required] Guid id,

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.IO;
 using System.IO;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;

+ 323 - 87
Jellyfin.Api/Controllers/ItemsController.cs

@@ -60,7 +60,6 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// <summary>
         /// Gets items based on a query.
         /// Gets items based on a query.
         /// </summary>
         /// </summary>
-        /// <param name="uId">The user id supplied in the /Users/{uid}/Items.</param>
         /// <param name="userId">The user id supplied as query parameter.</param>
         /// <param name="userId">The user id supplied as query parameter.</param>
         /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
         /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
         /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
         /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
@@ -143,10 +142,8 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableImages">Optional, include image information in output.</param>
         /// <param name="enableImages">Optional, include image information in output.</param>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
         [HttpGet("Items")]
         [HttpGet("Items")]
-        [HttpGet("Users/{uId}/Items", Name = "GetItems_2")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetItems(
         public ActionResult<QueryResult<BaseItemDto>> GetItems(
-            [FromRoute] Guid? uId,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? maxOfficialRating,
             [FromQuery] string? maxOfficialRating,
             [FromQuery] bool? hasThemeSong,
             [FromQuery] bool? hasThemeSong,
@@ -159,7 +156,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? hasParentalRating,
             [FromQuery] bool? hasParentalRating,
             [FromQuery] bool? isHd,
             [FromQuery] bool? isHd,
             [FromQuery] bool? is4K,
             [FromQuery] bool? is4K,
-            [FromQuery] string? locationTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
             [FromQuery] bool? isMissing,
             [FromQuery] bool? isMissing,
             [FromQuery] bool? isUnaired,
             [FromQuery] bool? isUnaired,
@@ -173,42 +170,42 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? hasImdbId,
             [FromQuery] bool? hasImdbId,
             [FromQuery] bool? hasTmdbId,
             [FromQuery] bool? hasTmdbId,
             [FromQuery] bool? hasTvdbId,
             [FromQuery] bool? hasTvdbId,
-            [FromQuery] string? excludeItemIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] bool? recursive,
             [FromQuery] bool? recursive,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
             [FromQuery] string? sortOrder,
             [FromQuery] string? sortOrder,
-            [FromQuery] string? parentId,
+            [FromQuery] Guid? parentId,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
             [FromQuery] string? sortBy,
             [FromQuery] string? sortBy,
             [FromQuery] bool? isPlayed,
             [FromQuery] bool? isPlayed,
-            [FromQuery] string? genres,
-            [FromQuery] string? officialRatings,
-            [FromQuery] string? tags,
-            [FromQuery] string? years,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? artists,
-            [FromQuery] string? excludeArtistIds,
-            [FromQuery] string? artistIds,
-            [FromQuery] string? albumArtistIds,
-            [FromQuery] string? contributingArtistIds,
-            [FromQuery] string? albums,
-            [FromQuery] string? albumIds,
-            [FromQuery] string? ids,
-            [FromQuery] string? videoTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
             [FromQuery] string? minOfficialRating,
             [FromQuery] string? minOfficialRating,
             [FromQuery] bool? isLocked,
             [FromQuery] bool? isLocked,
             [FromQuery] bool? isPlaceHolder,
             [FromQuery] bool? isPlaceHolder,
@@ -219,18 +216,15 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxHeight,
             [FromQuery] int? maxHeight,
             [FromQuery] bool? is3D,
             [FromQuery] bool? is3D,
-            [FromQuery] string? seriesStatus,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
             [FromQuery] string? nameStartsWith,
             [FromQuery] string? nameLessThan,
             [FromQuery] string? nameLessThan,
-            [FromQuery] string? studioIds,
-            [FromQuery] string? genreIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
             [FromQuery] bool enableTotalRecordCount = true,
             [FromQuery] bool enableTotalRecordCount = true,
             [FromQuery] bool? enableImages = true)
             [FromQuery] bool? enableImages = true)
         {
         {
-            // use user id route parameter over query parameter
-            userId = uId ?? userId;
-
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
@@ -238,20 +232,15 @@ namespace Jellyfin.Api.Controllers
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 
-            if (string.Equals(includeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase))
+            if (includeItemTypes.Length == 1
+                && (includeItemTypes[0].Equals("Playlist", StringComparison.OrdinalIgnoreCase)
+                    || includeItemTypes[0].Equals("BoxSet", StringComparison.OrdinalIgnoreCase)))
             {
             {
                 parentId = null;
                 parentId = null;
             }
             }
 
 
-            BaseItem? item = null;
+            var item = _libraryManager.GetParentItem(parentId, userId);
             QueryResult<BaseItem> result;
             QueryResult<BaseItem> result;
-            if (!string.IsNullOrEmpty(parentId))
-            {
-                item = _libraryManager.GetItemById(parentId);
-            }
-
-            item ??= _libraryManager.GetUserRootFolder();
 
 
             if (!(item is Folder folder))
             if (!(item is Folder folder))
             {
             {
@@ -262,7 +251,7 @@ namespace Jellyfin.Api.Controllers
                 && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
                 && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
             {
             {
                 recursive = true;
                 recursive = true;
-                includeItemTypes = "Playlist";
+                includeItemTypes = new[] { "Playlist" };
             }
             }
 
 
             bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id)
             bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id)
@@ -291,14 +280,14 @@ namespace Jellyfin.Api.Controllers
                 return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
                 return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
             }
             }
 
 
-            if ((recursive.HasValue && recursive.Value) || !string.IsNullOrEmpty(ids) || !(item is UserRootFolder))
+            if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || !(item is UserRootFolder))
             {
             {
                 var query = new InternalItemsQuery(user!)
                 var query = new InternalItemsQuery(user!)
                 {
                 {
                     IsPlayed = isPlayed,
                     IsPlayed = isPlayed,
-                    MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
-                    IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
-                    ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+                    MediaTypes = mediaTypes,
+                    IncludeItemTypes = includeItemTypes,
+                    ExcludeItemTypes = excludeItemTypes,
                     Recursive = recursive ?? false,
                     Recursive = recursive ?? false,
                     OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
                     OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
                     IsFavorite = isFavorite,
                     IsFavorite = isFavorite,
@@ -330,28 +319,28 @@ namespace Jellyfin.Api.Controllers
                     HasTrailer = hasTrailer,
                     HasTrailer = hasTrailer,
                     IsHD = isHd,
                     IsHD = isHd,
                     Is4K = is4K,
                     Is4K = is4K,
-                    Tags = RequestHelpers.Split(tags, '|', true),
-                    OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                    Genres = RequestHelpers.Split(genres, '|', true),
-                    ArtistIds = RequestHelpers.GetGuids(artistIds),
-                    AlbumArtistIds = RequestHelpers.GetGuids(albumArtistIds),
-                    ContributingArtistIds = RequestHelpers.GetGuids(contributingArtistIds),
-                    GenreIds = RequestHelpers.GetGuids(genreIds),
-                    StudioIds = RequestHelpers.GetGuids(studioIds),
+                    Tags = tags,
+                    OfficialRatings = officialRatings,
+                    Genres = genres,
+                    ArtistIds = artistIds,
+                    AlbumArtistIds = albumArtistIds,
+                    ContributingArtistIds = contributingArtistIds,
+                    GenreIds = genreIds,
+                    StudioIds = studioIds,
                     Person = person,
                     Person = person,
-                    PersonIds = RequestHelpers.GetGuids(personIds),
-                    PersonTypes = RequestHelpers.Split(personTypes, ',', true),
-                    Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+                    PersonIds = personIds,
+                    PersonTypes = personTypes,
+                    Years = years,
                     ImageTypes = imageTypes,
                     ImageTypes = imageTypes,
-                    VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(),
+                    VideoTypes = videoTypes,
                     AdjacentTo = adjacentTo,
                     AdjacentTo = adjacentTo,
-                    ItemIds = RequestHelpers.GetGuids(ids),
+                    ItemIds = ids,
                     MinCommunityRating = minCommunityRating,
                     MinCommunityRating = minCommunityRating,
                     MinCriticRating = minCriticRating,
                     MinCriticRating = minCriticRating,
-                    ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId),
+                    ParentId = parentId ?? Guid.Empty,
                     ParentIndexNumber = parentIndexNumber,
                     ParentIndexNumber = parentIndexNumber,
                     EnableTotalRecordCount = enableTotalRecordCount,
                     EnableTotalRecordCount = enableTotalRecordCount,
-                    ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds),
+                    ExcludeItemIds = excludeItemIds,
                     DtoOptions = dtoOptions,
                     DtoOptions = dtoOptions,
                     SearchTerm = searchTerm,
                     SearchTerm = searchTerm,
                     MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
                     MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
@@ -360,7 +349,7 @@ namespace Jellyfin.Api.Controllers
                     MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
                     MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
                 };
                 };
 
 
-                if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm))
+                if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
                 {
                 {
                     query.CollapseBoxSetItems = false;
                     query.CollapseBoxSetItems = false;
                 }
                 }
@@ -400,9 +389,9 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
 
 
                 // Filter by Series Status
                 // Filter by Series Status
-                if (!string.IsNullOrEmpty(seriesStatus))
+                if (seriesStatus.Length != 0)
                 {
                 {
-                    query.SeriesStatuses = seriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray();
+                    query.SeriesStatuses = seriesStatus;
                 }
                 }
 
 
                 // ExcludeLocationTypes
                 // ExcludeLocationTypes
@@ -411,13 +400,9 @@ namespace Jellyfin.Api.Controllers
                     query.IsVirtualItem = false;
                     query.IsVirtualItem = false;
                 }
                 }
 
 
-                if (!string.IsNullOrEmpty(locationTypes))
+                if (locationTypes.Length > 0 && locationTypes.Length < 4)
                 {
                 {
-                    var requestedLocationTypes = locationTypes.Split(',');
-                    if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4)
-                    {
-                        query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString());
-                    }
+                    query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
                 }
                 }
 
 
                 // Min official rating
                 // Min official rating
@@ -433,9 +418,9 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
 
 
                 // Artists
                 // Artists
-                if (!string.IsNullOrEmpty(artists))
+                if (artists.Length != 0)
                 {
                 {
-                    query.ArtistIds = artists.Split('|').Select(i =>
+                    query.ArtistIds = artists.Select(i =>
                     {
                     {
                         try
                         try
                         {
                         {
@@ -449,29 +434,29 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
 
 
                 // ExcludeArtistIds
                 // ExcludeArtistIds
-                if (!string.IsNullOrWhiteSpace(excludeArtistIds))
+                if (excludeArtistIds.Length != 0)
                 {
                 {
-                    query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
+                    query.ExcludeArtistIds = excludeArtistIds;
                 }
                 }
 
 
-                if (!string.IsNullOrWhiteSpace(albumIds))
+                if (albumIds.Length != 0)
                 {
                 {
-                    query.AlbumIds = RequestHelpers.GetGuids(albumIds);
+                    query.AlbumIds = albumIds;
                 }
                 }
 
 
                 // Albums
                 // Albums
-                if (!string.IsNullOrEmpty(albums))
+                if (albums.Length != 0)
                 {
                 {
-                    query.AlbumIds = albums.Split('|').SelectMany(i =>
+                    query.AlbumIds = albums.SelectMany(i =>
                     {
                     {
                         return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 });
                         return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 });
                     }).ToArray();
                     }).ToArray();
                 }
                 }
 
 
                 // Studios
                 // Studios
-                if (!string.IsNullOrEmpty(studios))
+                if (studios.Length != 0)
                 {
                 {
-                    query.StudioIds = studios.Split('|').Select(i =>
+                    query.StudioIds = studios.Select(i =>
                     {
                     {
                         try
                         try
                         {
                         {
@@ -505,6 +490,257 @@ namespace Jellyfin.Api.Controllers
             return new QueryResult<BaseItemDto> { StartIndex = startIndex.GetValueOrDefault(), TotalRecordCount = result.TotalRecordCount, Items = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user) };
             return new QueryResult<BaseItemDto> { StartIndex = startIndex.GetValueOrDefault(), TotalRecordCount = result.TotalRecordCount, Items = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user) };
         }
         }
 
 
+        /// <summary>
+        /// Gets items based on a query.
+        /// </summary>
+        /// <param name="userId">The user id supplied as query parameter.</param>
+        /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
+        /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
+        /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
+        /// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
+        /// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
+        /// <param name="hasTrailer">Optional filter by items with trailers.</param>
+        /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+        /// <param name="parentIndexNumber">Optional filter by parent index number.</param>
+        /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
+        /// <param name="isHd">Optional filter by items that are HD or not.</param>
+        /// <param name="is4K">Optional filter by items that are 4K or not.</param>
+        /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param>
+        /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param>
+        /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
+        /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
+        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+        /// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
+        /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
+        /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
+        /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
+        /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
+        /// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
+        /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param>
+        /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param>
+        /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param>
+        /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param>
+        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+        /// <param name="limit">Optional. The maximum number of records to return.</param>
+        /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
+        /// <param name="searchTerm">Optional. Filter based on a search term.</param>
+        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
+        /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+        /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
+        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+        /// <param name="isPlayed">Optional filter by items that are played, or not.</param>
+        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param>
+        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
+        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
+        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param>
+        /// <param name="enableUserData">Optional, include user data.</param>
+        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
+        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param>
+        /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
+        /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param>
+        /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
+        /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
+        /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
+        /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param>
+        /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param>
+        /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
+        /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param>
+        /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
+        /// <param name="isLocked">Optional filter by items that are locked.</param>
+        /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
+        /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
+        /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
+        /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
+        /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
+        /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
+        /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
+        /// <param name="is3D">Optional filter by items that are 3D, or not.</param>
+        /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param>
+        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param>
+        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param>
+        /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+        /// <param name="enableImages">Optional, include image information in output.</param>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
+        [HttpGet("Users/{userId}/Items")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId(
+            [FromRoute] Guid userId,
+            [FromQuery] string? maxOfficialRating,
+            [FromQuery] bool? hasThemeSong,
+            [FromQuery] bool? hasThemeVideo,
+            [FromQuery] bool? hasSubtitles,
+            [FromQuery] bool? hasSpecialFeature,
+            [FromQuery] bool? hasTrailer,
+            [FromQuery] string? adjacentTo,
+            [FromQuery] int? parentIndexNumber,
+            [FromQuery] bool? hasParentalRating,
+            [FromQuery] bool? isHd,
+            [FromQuery] bool? is4K,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
+            [FromQuery] bool? isMissing,
+            [FromQuery] bool? isUnaired,
+            [FromQuery] double? minCommunityRating,
+            [FromQuery] double? minCriticRating,
+            [FromQuery] DateTime? minPremiereDate,
+            [FromQuery] DateTime? minDateLastSaved,
+            [FromQuery] DateTime? minDateLastSavedForUser,
+            [FromQuery] DateTime? maxPremiereDate,
+            [FromQuery] bool? hasOverview,
+            [FromQuery] bool? hasImdbId,
+            [FromQuery] bool? hasTmdbId,
+            [FromQuery] bool? hasTvdbId,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] bool? recursive,
+            [FromQuery] string? searchTerm,
+            [FromQuery] string? sortOrder,
+            [FromQuery] Guid? parentId,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
+            [FromQuery] string? sortBy,
+            [FromQuery] bool? isPlayed,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+            [FromQuery] string? person,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
+            [FromQuery] string? minOfficialRating,
+            [FromQuery] bool? isLocked,
+            [FromQuery] bool? isPlaceHolder,
+            [FromQuery] bool? hasOfficialRating,
+            [FromQuery] bool? collapseBoxSetItems,
+            [FromQuery] int? minWidth,
+            [FromQuery] int? minHeight,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] bool? is3D,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
+            [FromQuery] string? nameStartsWithOrGreater,
+            [FromQuery] string? nameStartsWith,
+            [FromQuery] string? nameLessThan,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+            [FromQuery] bool enableTotalRecordCount = true,
+            [FromQuery] bool? enableImages = true)
+        {
+            return GetItems(
+                userId,
+                maxOfficialRating,
+                hasThemeSong,
+                hasThemeVideo,
+                hasSubtitles,
+                hasSpecialFeature,
+                hasTrailer,
+                adjacentTo,
+                parentIndexNumber,
+                hasParentalRating,
+                isHd,
+                is4K,
+                locationTypes,
+                excludeLocationTypes,
+                isMissing,
+                isUnaired,
+                minCommunityRating,
+                minCriticRating,
+                minPremiereDate,
+                minDateLastSaved,
+                minDateLastSavedForUser,
+                maxPremiereDate,
+                hasOverview,
+                hasImdbId,
+                hasTmdbId,
+                hasTvdbId,
+                excludeItemIds,
+                startIndex,
+                limit,
+                recursive,
+                searchTerm,
+                sortOrder,
+                parentId,
+                fields,
+                excludeItemTypes,
+                includeItemTypes,
+                filters,
+                isFavorite,
+                mediaTypes,
+                imageTypes,
+                sortBy,
+                isPlayed,
+                genres,
+                officialRatings,
+                tags,
+                years,
+                enableUserData,
+                imageTypeLimit,
+                enableImageTypes,
+                person,
+                personIds,
+                personTypes,
+                studios,
+                artists,
+                excludeArtistIds,
+                artistIds,
+                albumArtistIds,
+                contributingArtistIds,
+                albums,
+                albumIds,
+                ids,
+                videoTypes,
+                minOfficialRating,
+                isLocked,
+                isPlaceHolder,
+                hasOfficialRating,
+                collapseBoxSetItems,
+                minWidth,
+                minHeight,
+                maxWidth,
+                maxHeight,
+                is3D,
+                seriesStatus,
+                nameStartsWithOrGreater,
+                nameStartsWith,
+                nameLessThan,
+                studioIds,
+                genreIds,
+                enableTotalRecordCount,
+                enableImages);
+        }
+
         /// <summary>
         /// <summary>
         /// Gets items based on a query.
         /// Gets items based on a query.
         /// </summary>
         /// </summary>
@@ -531,19 +767,19 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
-            [FromQuery] string? parentId,
+            [FromQuery] Guid? parentId,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? mediaTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
             [FromQuery] bool enableTotalRecordCount = true,
             [FromQuery] bool enableTotalRecordCount = true,
             [FromQuery] bool? enableImages = true)
             [FromQuery] bool? enableImages = true)
         {
         {
             var user = _userManager.GetUserById(userId);
             var user = _userManager.GetUserById(userId);
-            var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+            var parentIdGuid = parentId ?? Guid.Empty;
             var dtoOptions = new DtoOptions { Fields = fields }
             var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
@@ -569,13 +805,13 @@ namespace Jellyfin.Api.Controllers
                 ParentId = parentIdGuid,
                 ParentId = parentIdGuid,
                 Recursive = true,
                 Recursive = true,
                 DtoOptions = dtoOptions,
                 DtoOptions = dtoOptions,
-                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+                MediaTypes = mediaTypes,
                 IsVirtualItem = false,
                 IsVirtualItem = false,
                 CollapseBoxSetItems = false,
                 CollapseBoxSetItems = false,
                 EnableTotalRecordCount = enableTotalRecordCount,
                 EnableTotalRecordCount = enableTotalRecordCount,
                 AncestorIds = ancestorIds,
                 AncestorIds = ancestorIds,
-                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
-                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+                IncludeItemTypes = includeItemTypes,
+                ExcludeItemTypes = excludeItemTypes,
                 SearchTerm = searchTerm
                 SearchTerm = searchTerm
             });
             });
 
 

+ 6 - 7
Jellyfin.Api/Controllers/LibraryController.cs

@@ -362,15 +362,14 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status401Unauthorized)]
         [ProducesResponseType(StatusCodes.Status401Unauthorized)]
-        public ActionResult DeleteItems([FromQuery] string? ids)
+        public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
         {
         {
-            if (string.IsNullOrEmpty(ids))
+            if (ids.Length == 0)
             {
             {
                 return NoContent();
                 return NoContent();
             }
             }
 
 
-            var itemIds = RequestHelpers.Split(ids, ',', true);
-            foreach (var i in itemIds)
+            foreach (var i in ids)
             {
             {
                 var item = _libraryManager.GetItemById(i);
                 var item = _libraryManager.GetItemById(i);
                 var auth = _authContext.GetAuthorizationInfo(Request);
                 var auth = _authContext.GetAuthorizationInfo(Request);
@@ -691,7 +690,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
         public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] Guid itemId,
-            [FromQuery] string? excludeArtistIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
@@ -753,9 +752,9 @@ namespace Jellyfin.Api.Controllers
             };
             };
 
 
             // ExcludeArtistIds
             // ExcludeArtistIds
-            if (!string.IsNullOrEmpty(excludeArtistIds))
+            if (excludeArtistIds.Length != 0)
             {
             {
-                query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
+                query.ExcludeArtistIds = excludeArtistIds;
             }
             }
 
 
             List<BaseItem> itemsResult = _libraryManager.GetItemList(query);
             List<BaseItem> itemsResult = _libraryManager.GetItemList(query);

+ 17 - 18
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Diagnostics.CodeAnalysis;
 using System.Diagnostics.CodeAnalysis;
@@ -17,7 +17,6 @@ using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.LiveTvDtos;
 using Jellyfin.Api.Models.LiveTvDtos;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
-using MediaBrowser.Common;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
@@ -150,7 +149,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
-            [FromQuery] string? sortBy,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
             [FromQuery] SortOrder? sortOrder,
             [FromQuery] SortOrder? sortOrder,
             [FromQuery] bool enableFavoriteSorting = false,
             [FromQuery] bool enableFavoriteSorting = false,
             [FromQuery] bool addCurrentProgram = true)
             [FromQuery] bool addCurrentProgram = true)
@@ -175,7 +174,7 @@ namespace Jellyfin.Api.Controllers
                     IsNews = isNews,
                     IsNews = isNews,
                     IsKids = isKids,
                     IsKids = isKids,
                     IsSports = isSports,
                     IsSports = isSports,
-                    SortBy = RequestHelpers.Split(sortBy, ',', true),
+                    SortBy = sortBy,
                     SortOrder = sortOrder ?? SortOrder.Ascending,
                     SortOrder = sortOrder ?? SortOrder.Ascending,
                     AddCurrentProgram = addCurrentProgram
                     AddCurrentProgram = addCurrentProgram
                 },
                 },
@@ -539,7 +538,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms(
         public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms(
-            [FromQuery] string? channelIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] DateTime? minStartDate,
             [FromQuery] DateTime? minStartDate,
             [FromQuery] bool? hasAired,
             [FromQuery] bool? hasAired,
@@ -556,8 +555,8 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? sortBy,
             [FromQuery] string? sortBy,
             [FromQuery] string? sortOrder,
             [FromQuery] string? sortOrder,
-            [FromQuery] string? genres,
-            [FromQuery] string? genreIds,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -573,8 +572,7 @@ namespace Jellyfin.Api.Controllers
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
-                ChannelIds = RequestHelpers.Split(channelIds, ',', true)
-                    .Select(i => new Guid(i)).ToArray(),
+                ChannelIds = channelIds,
                 HasAired = hasAired,
                 HasAired = hasAired,
                 IsAiring = isAiring,
                 IsAiring = isAiring,
                 EnableTotalRecordCount = enableTotalRecordCount,
                 EnableTotalRecordCount = enableTotalRecordCount,
@@ -591,8 +589,8 @@ namespace Jellyfin.Api.Controllers
                 IsKids = isKids,
                 IsKids = isKids,
                 IsSports = isSports,
                 IsSports = isSports,
                 SeriesTimerId = seriesTimerId,
                 SeriesTimerId = seriesTimerId,
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds)
+                Genres = genres,
+                GenreIds = genreIds
             };
             };
 
 
             if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty))
             if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty))
@@ -628,8 +626,7 @@ namespace Jellyfin.Api.Controllers
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
-                ChannelIds = RequestHelpers.Split(body.ChannelIds, ',', true)
-                    .Select(i => new Guid(i)).ToArray(),
+                ChannelIds = body.ChannelIds,
                 HasAired = body.HasAired,
                 HasAired = body.HasAired,
                 IsAiring = body.IsAiring,
                 IsAiring = body.IsAiring,
                 EnableTotalRecordCount = body.EnableTotalRecordCount,
                 EnableTotalRecordCount = body.EnableTotalRecordCount,
@@ -646,8 +643,8 @@ namespace Jellyfin.Api.Controllers
                 IsKids = body.IsKids,
                 IsKids = body.IsKids,
                 IsSports = body.IsSports,
                 IsSports = body.IsSports,
                 SeriesTimerId = body.SeriesTimerId,
                 SeriesTimerId = body.SeriesTimerId,
-                Genres = RequestHelpers.Split(body.Genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(body.GenreIds)
+                Genres = body.Genres,
+                GenreIds = body.GenreIds
             };
             };
 
 
             if (!body.LibrarySeriesId.Equals(Guid.Empty))
             if (!body.LibrarySeriesId.Equals(Guid.Empty))
@@ -703,7 +700,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] string? genreIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool enableTotalRecordCount = true)
             [FromQuery] bool enableTotalRecordCount = true)
@@ -723,7 +720,7 @@ namespace Jellyfin.Api.Controllers
                 IsNews = isNews,
                 IsNews = isNews,
                 IsSports = isSports,
                 IsSports = isSports,
                 EnableTotalRecordCount = enableTotalRecordCount,
                 EnableTotalRecordCount = enableTotalRecordCount,
-                GenreIds = RequestHelpers.GetGuids(genreIds)
+                GenreIds = genreIds
             };
             };
 
 
             var dtoOptions = new DtoOptions { Fields = fields }
             var dtoOptions = new DtoOptions { Fields = fields }
@@ -1017,7 +1014,9 @@ namespace Jellyfin.Api.Controllers
             if (!string.IsNullOrEmpty(pw))
             if (!string.IsNullOrEmpty(pw))
             {
             {
                 using var sha = SHA1.Create();
                 using var sha = SHA1.Create();
-                listingsProviderInfo.Password = Hex.Encode(sha.ComputeHash(Encoding.UTF8.GetBytes(pw)));
+                // TODO: remove ToLower when Convert.ToHexString supports lowercase
+                // Schedules Direct requires the hex to be lowercase
+                listingsProviderInfo.Password = Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
             }
             }
 
 
             return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
             return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);

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

@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Globalization;

+ 39 - 21
Jellyfin.Api/Controllers/MediaInfoController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Buffers;
 using System.Buffers;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;
@@ -8,7 +8,6 @@ using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Models.MediaInfoDtos;
 using Jellyfin.Api.Models.MediaInfoDtos;
-using Jellyfin.Api.Models.VideoDtos;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
@@ -81,6 +80,9 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// <summary>
         /// Gets live playback media info for an item.
         /// Gets live playback media info for an item.
         /// </summary>
         /// </summary>
+        /// <remarks>
+        /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.
+        /// </remarks>
         /// <param name="itemId">The item id.</param>
         /// <param name="itemId">The item id.</param>
         /// <param name="userId">The user id.</param>
         /// <param name="userId">The user id.</param>
         /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
         /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
@@ -90,13 +92,13 @@ namespace Jellyfin.Api.Controllers
         /// <param name="maxAudioChannels">The maximum number of audio channels.</param>
         /// <param name="maxAudioChannels">The maximum number of audio channels.</param>
         /// <param name="mediaSourceId">The media source id.</param>
         /// <param name="mediaSourceId">The media source id.</param>
         /// <param name="liveStreamId">The livestream id.</param>
         /// <param name="liveStreamId">The livestream id.</param>
-        /// <param name="deviceProfile">The device profile.</param>
         /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param>
         /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param>
         /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
         /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
         /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
         /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
         /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param>
         /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param>
         /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param>
         /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param>
         /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param>
         /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param>
+        /// <param name="playbackInfoDto">The playback info.</param>
         /// <response code="200">Playback info returned.</response>
         /// <response code="200">Playback info returned.</response>
         /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns>
         /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns>
         [HttpPost("Items/{itemId}/PlaybackInfo")]
         [HttpPost("Items/{itemId}/PlaybackInfo")]
@@ -111,18 +113,17 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? maxAudioChannels,
             [FromQuery] int? maxAudioChannels,
             [FromQuery] string? mediaSourceId,
             [FromQuery] string? mediaSourceId,
             [FromQuery] string? liveStreamId,
             [FromQuery] string? liveStreamId,
-            [FromBody] DeviceProfileDto? deviceProfile,
-            [FromQuery] bool autoOpenLiveStream = false,
-            [FromQuery] bool enableDirectPlay = true,
-            [FromQuery] bool enableDirectStream = true,
-            [FromQuery] bool enableTranscoding = true,
-            [FromQuery] bool allowVideoStreamCopy = true,
-            [FromQuery] bool allowAudioStreamCopy = true)
+            [FromQuery] bool? autoOpenLiveStream,
+            [FromQuery] bool? enableDirectPlay,
+            [FromQuery] bool? enableDirectStream,
+            [FromQuery] bool? enableTranscoding,
+            [FromQuery] bool? allowVideoStreamCopy,
+            [FromQuery] bool? allowAudioStreamCopy,
+            [FromBody] PlaybackInfoDto? playbackInfoDto)
         {
         {
             var authInfo = _authContext.GetAuthorizationInfo(Request);
             var authInfo = _authContext.GetAuthorizationInfo(Request);
 
 
-            var profile = deviceProfile?.DeviceProfile;
-
+            var profile = playbackInfoDto?.DeviceProfile;
             _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile);
             _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile);
 
 
             if (profile == null)
             if (profile == null)
@@ -134,6 +135,23 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
             }
             }
 
 
+            // Copy params from posted body
+            // TODO clean up when breaking API compatibility.
+            userId ??= playbackInfoDto?.UserId;
+            maxStreamingBitrate ??= playbackInfoDto?.MaxStreamingBitrate;
+            startTimeTicks ??= playbackInfoDto?.StartTimeTicks;
+            audioStreamIndex ??= playbackInfoDto?.AudioStreamIndex;
+            subtitleStreamIndex ??= playbackInfoDto?.SubtitleStreamIndex;
+            maxAudioChannels ??= playbackInfoDto?.MaxAudioChannels;
+            mediaSourceId ??= playbackInfoDto?.MediaSourceId;
+            liveStreamId ??= playbackInfoDto?.LiveStreamId;
+            autoOpenLiveStream ??= playbackInfoDto?.AutoOpenLiveStream ?? false;
+            enableDirectPlay ??= playbackInfoDto?.EnableDirectPlay ?? true;
+            enableDirectStream ??= playbackInfoDto?.EnableDirectStream ?? true;
+            enableTranscoding ??= playbackInfoDto?.EnableTranscoding ?? true;
+            allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true;
+            allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true;
+
             var info = await _mediaInfoHelper.GetPlaybackInfo(
             var info = await _mediaInfoHelper.GetPlaybackInfo(
                     itemId,
                     itemId,
                     userId,
                     userId,
@@ -161,18 +179,18 @@ namespace Jellyfin.Api.Controllers
                         maxAudioChannels,
                         maxAudioChannels,
                         info!.PlaySessionId!,
                         info!.PlaySessionId!,
                         userId ?? Guid.Empty,
                         userId ?? Guid.Empty,
-                        enableDirectPlay,
-                        enableDirectStream,
-                        enableTranscoding,
-                        allowVideoStreamCopy,
-                        allowAudioStreamCopy,
+                        enableDirectPlay.Value,
+                        enableDirectStream.Value,
+                        enableTranscoding.Value,
+                        allowVideoStreamCopy.Value,
+                        allowAudioStreamCopy.Value,
                         Request.HttpContext.GetNormalizedRemoteIp());
                         Request.HttpContext.GetNormalizedRemoteIp());
                 }
                 }
 
 
                 _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
                 _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
             }
             }
 
 
-            if (autoOpenLiveStream)
+            if (autoOpenLiveStream.Value)
             {
             {
                 var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal));
                 var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal));
 
 
@@ -183,9 +201,9 @@ namespace Jellyfin.Api.Controllers
                         new LiveStreamRequest
                         new LiveStreamRequest
                         {
                         {
                             AudioStreamIndex = audioStreamIndex,
                             AudioStreamIndex = audioStreamIndex,
-                            DeviceProfile = deviceProfile?.DeviceProfile,
-                            EnableDirectPlay = enableDirectPlay,
-                            EnableDirectStream = enableDirectStream,
+                            DeviceProfile = playbackInfoDto?.DeviceProfile,
+                            EnableDirectPlay = enableDirectPlay.Value,
+                            EnableDirectStream = enableDirectStream.Value,
                             ItemId = itemId,
                             ItemId = itemId,
                             MaxAudioChannels = maxAudioChannels,
                             MaxAudioChannels = maxAudioChannels,
                             MaxStreamingBitrate = maxStreamingBitrate,
                             MaxStreamingBitrate = maxStreamingBitrate,

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Globalization;
 using System.Linq;
 using System.Linq;
@@ -65,7 +65,7 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("Recommendations")]
         [HttpGet("Recommendations")]
         public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
         public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
-            [FromQuery] string? parentId,
+            [FromQuery] Guid? parentId,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] int categoryLimit = 5,
             [FromQuery] int categoryLimit = 5,
             [FromQuery] int itemLimit = 8)
             [FromQuery] int itemLimit = 8)
@@ -78,7 +78,7 @@ namespace Jellyfin.Api.Controllers
 
 
             var categories = new List<RecommendationDto>();
             var categories = new List<RecommendationDto>();
 
 
-            var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+            var parentIdGuid = parentId ?? Guid.Empty;
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {

+ 10 - 10
Jellyfin.Api/Controllers/MusicGenresController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
@@ -72,10 +72,10 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
-            [FromQuery] string? parentId,
+            [FromQuery] Guid? parentId,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
             [FromQuery] bool? isFavorite,
             [FromQuery] bool? isFavorite,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
-                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
-                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                ExcludeItemTypes = excludeItemTypes,
+                IncludeItemTypes = includeItemTypes,
                 StartIndex = startIndex,
                 StartIndex = startIndex,
                 Limit = limit,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 IsFavorite = isFavorite,
@@ -109,21 +109,21 @@ namespace Jellyfin.Api.Controllers
                 EnableTotalRecordCount = enableTotalRecordCount
                 EnableTotalRecordCount = enableTotalRecordCount
             };
             };
 
 
-            if (!string.IsNullOrWhiteSpace(parentId))
+            if (parentId.HasValue)
             {
             {
                 if (parentItem is Folder)
                 if (parentItem is Folder)
                 {
                 {
-                    query.AncestorIds = new[] { new Guid(parentId) };
+                    query.AncestorIds = new[] { parentId.Value };
                 }
                 }
                 else
                 else
                 {
                 {
-                    query.ItemIds = new[] { new Guid(parentId) };
+                    query.ItemIds = new[] { parentId.Value };
                 }
                 }
             }
             }
 
 
             var result = _libraryManager.GetMusicGenres(query);
             var result = _libraryManager.GetMusicGenres(query);
 
 
-            var shouldIncludeItemTypes = !string.IsNullOrWhiteSpace(includeItemTypes);
+            var shouldIncludeItemTypes = includeItemTypes.Length != 0;
             return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
             return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
         }
 
 

+ 5 - 5
Jellyfin.Api/Controllers/PackageController.cs

@@ -45,13 +45,13 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public async Task<ActionResult<PackageInfo>> GetPackageInfo(
         public async Task<ActionResult<PackageInfo>> GetPackageInfo(
             [FromRoute, Required] string name,
             [FromRoute, Required] string name,
-            [FromQuery] string? assemblyGuid)
+            [FromQuery] Guid? assemblyGuid)
         {
         {
             var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
             var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
             var result = _installationManager.FilterPackages(
             var result = _installationManager.FilterPackages(
                     packages,
                     packages,
                     name,
                     name,
-                    string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid))
+                    assemblyGuid ?? default)
                 .FirstOrDefault();
                 .FirstOrDefault();
 
 
             if (result == null)
             if (result == null)
@@ -92,21 +92,21 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.RequiresElevation)]
         [Authorize(Policy = Policies.RequiresElevation)]
         public async Task<ActionResult> InstallPackage(
         public async Task<ActionResult> InstallPackage(
             [FromRoute, Required] string name,
             [FromRoute, Required] string name,
-            [FromQuery] string? assemblyGuid,
+            [FromQuery] Guid? assemblyGuid,
             [FromQuery] string? version,
             [FromQuery] string? version,
             [FromQuery] string? repositoryUrl)
             [FromQuery] string? repositoryUrl)
         {
         {
             var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
             var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
             if (!string.IsNullOrEmpty(repositoryUrl))
             if (!string.IsNullOrEmpty(repositoryUrl))
             {
             {
-                packages = packages.Where(p => p.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase))
+                packages = packages.Where(p => p.versions.Where(q => q.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)).Any())
                     .ToList();
                     .ToList();
             }
             }
 
 
             var package = _installationManager.GetCompatibleVersions(
             var package = _installationManager.GetCompatibleVersions(
                     packages,
                     packages,
                     name,
                     name,
-                    string.IsNullOrEmpty(assemblyGuid) ? Guid.Empty : Guid.Parse(assemblyGuid),
+                    assemblyGuid ?? Guid.Empty,
                     specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version))
                     specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version))
                 .FirstOrDefault();
                 .FirstOrDefault();
 
 

+ 7 - 7
Jellyfin.Api/Controllers/PersonsController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
@@ -77,9 +77,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] string? excludePersonTypes,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? appearsInItemId,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+            [FromQuery] Guid? appearsInItemId,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] bool? enableImages = true)
             [FromQuery] bool? enableImages = true)
         {
         {
@@ -97,12 +97,12 @@ namespace Jellyfin.Api.Controllers
             var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite);
             var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite);
             var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery
             var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery
             {
             {
-                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
-                ExcludePersonTypes = RequestHelpers.Split(excludePersonTypes, ',', true),
+                PersonTypes = personTypes,
+                ExcludePersonTypes = excludePersonTypes,
                 NameContains = searchTerm,
                 NameContains = searchTerm,
                 User = user,
                 User = user,
                 IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite,
                 IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite,
-                AppearsInItemId = string.IsNullOrEmpty(appearsInItemId) ? Guid.Empty : Guid.Parse(appearsInItemId),
+                AppearsInItemId = appearsInItemId ?? Guid.Empty,
                 Limit = limit ?? 0
                 Limit = limit ?? 0
             });
             });
 
 

+ 8 - 7
Jellyfin.Api/Controllers/PlaylistsController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
@@ -63,11 +63,10 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
         public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
             [FromBody, Required] CreatePlaylistDto createPlaylistRequest)
             [FromBody, Required] CreatePlaylistDto createPlaylistRequest)
         {
         {
-            Guid[] idGuidArray = RequestHelpers.GetGuids(createPlaylistRequest.Ids);
             var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
             var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
             {
             {
                 Name = createPlaylistRequest.Name,
                 Name = createPlaylistRequest.Name,
-                ItemIdList = idGuidArray,
+                ItemIdList = createPlaylistRequest.Ids,
                 UserId = createPlaylistRequest.UserId,
                 UserId = createPlaylistRequest.UserId,
                 MediaType = createPlaylistRequest.MediaType
                 MediaType = createPlaylistRequest.MediaType
             }).ConfigureAwait(false);
             }).ConfigureAwait(false);
@@ -87,10 +86,10 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public async Task<ActionResult> AddToPlaylist(
         public async Task<ActionResult> AddToPlaylist(
             [FromRoute, Required] Guid playlistId,
             [FromRoute, Required] Guid playlistId,
-            [FromQuery] string? ids,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
             [FromQuery] Guid? userId)
             [FromQuery] Guid? userId)
         {
         {
-            await _playlistManager.AddToPlaylistAsync(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty).ConfigureAwait(false);
+            await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false);
             return NoContent();
             return NoContent();
         }
         }
 
 
@@ -122,9 +121,11 @@ namespace Jellyfin.Api.Controllers
         /// <returns>An <see cref="NoContentResult"/> on success.</returns>
         /// <returns>An <see cref="NoContentResult"/> on success.</returns>
         [HttpDelete("{playlistId}/Items")]
         [HttpDelete("{playlistId}/Items")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> RemoveFromPlaylist([FromRoute, Required] string playlistId, [FromQuery] string? entryIds)
+        public async Task<ActionResult> RemoveFromPlaylist(
+            [FromRoute, Required] string playlistId,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds)
         {
         {
-            await _playlistManager.RemoveFromPlaylistAsync(playlistId, RequestHelpers.Split(entryIds, ',', true)).ConfigureAwait(false);
+            await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
             return NoContent();
             return NoContent();
         }
         }
 
 

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

@@ -1,9 +1,10 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Diagnostics.CodeAnalysis;
 using System.Diagnostics.CodeAnalysis;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
@@ -74,7 +75,7 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<UserItemDataDto> MarkPlayedItem(
         public ActionResult<UserItemDataDto> MarkPlayedItem(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] Guid itemId,
-            [FromQuery] DateTime? datePlayed)
+            [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
         {
         {
             var user = _userManager.GetUserById(userId);
             var user = _userManager.GetUserById(userId);
             var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
             var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;

+ 8 - 7
Jellyfin.Api/Controllers/SearchController.cs

@@ -5,6 +5,7 @@ using System.Globalization;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
@@ -82,10 +83,10 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery, Required] string searchTerm,
             [FromQuery, Required] string searchTerm,
-            [FromQuery] string? includeItemTypes,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? mediaTypes,
-            [FromQuery] string? parentId,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+            [FromQuery] Guid? parentId,
             [FromQuery] bool? isMovie,
             [FromQuery] bool? isMovie,
             [FromQuery] bool? isSeries,
             [FromQuery] bool? isSeries,
             [FromQuery] bool? isNews,
             [FromQuery] bool? isNews,
@@ -108,9 +109,9 @@ namespace Jellyfin.Api.Controllers
                 IncludeStudios = includeStudios,
                 IncludeStudios = includeStudios,
                 StartIndex = startIndex,
                 StartIndex = startIndex,
                 UserId = userId ?? Guid.Empty,
                 UserId = userId ?? Guid.Empty,
-                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
-                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
-                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+                IncludeItemTypes = includeItemTypes,
+                ExcludeItemTypes = excludeItemTypes,
+                MediaTypes = mediaTypes,
                 ParentId = parentId,
                 ParentId = parentId,
 
 
                 IsKids = isKids,
                 IsKids = isKids,

+ 7 - 6
Jellyfin.Api/Controllers/SessionController.cs

@@ -6,6 +6,7 @@ using System.Threading;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.ModelBinders;
+using Jellyfin.Api.Models.SessionDtos;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
@@ -160,12 +161,12 @@ namespace Jellyfin.Api.Controllers
         public ActionResult Play(
         public ActionResult Play(
             [FromRoute, Required] string sessionId,
             [FromRoute, Required] string sessionId,
             [FromQuery, Required] PlayCommand playCommand,
             [FromQuery, Required] PlayCommand playCommand,
-            [FromQuery, Required] string itemIds,
+            [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds,
             [FromQuery] long? startPositionTicks)
             [FromQuery] long? startPositionTicks)
         {
         {
             var playRequest = new PlayRequest
             var playRequest = new PlayRequest
             {
             {
-                ItemIds = RequestHelpers.GetGuids(itemIds),
+                ItemIds = itemIds,
                 StartPositionTicks = startPositionTicks,
                 StartPositionTicks = startPositionTicks,
                 PlayCommand = playCommand
                 PlayCommand = playCommand
             };
             };
@@ -378,7 +379,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult PostCapabilities(
         public ActionResult PostCapabilities(
             [FromQuery] string? id,
             [FromQuery] string? id,
-            [FromQuery] string? playableMediaTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands,
             [FromQuery] bool supportsMediaControl = false,
             [FromQuery] bool supportsMediaControl = false,
             [FromQuery] bool supportsSync = false,
             [FromQuery] bool supportsSync = false,
@@ -391,7 +392,7 @@ namespace Jellyfin.Api.Controllers
 
 
             _sessionManager.ReportCapabilities(id, new ClientCapabilities
             _sessionManager.ReportCapabilities(id, new ClientCapabilities
             {
             {
-                PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true),
+                PlayableMediaTypes = playableMediaTypes,
                 SupportedCommands = supportedCommands,
                 SupportedCommands = supportedCommands,
                 SupportsMediaControl = supportsMediaControl,
                 SupportsMediaControl = supportsMediaControl,
                 SupportsSync = supportsSync,
                 SupportsSync = supportsSync,
@@ -412,14 +413,14 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult PostFullCapabilities(
         public ActionResult PostFullCapabilities(
             [FromQuery] string? id,
             [FromQuery] string? id,
-            [FromBody, Required] ClientCapabilities capabilities)
+            [FromBody, Required] ClientCapabilitiesDto capabilities)
         {
         {
             if (string.IsNullOrWhiteSpace(id))
             if (string.IsNullOrWhiteSpace(id))
             {
             {
                 id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
                 id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
             }
             }
 
 
-            _sessionManager.ReportCapabilities(id, capabilities);
+            _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
 
 
             return NoContent();
             return NoContent();
         }
         }

+ 8 - 6
Jellyfin.Api/Controllers/StartupController.cs

@@ -3,6 +3,7 @@ using System.Linq;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Models.StartupDtos;
 using Jellyfin.Api.Models.StartupDtos;
+using Jellyfin.Networking.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Authorization;
@@ -72,9 +73,9 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration)
         public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration)
         {
         {
-            _config.Configuration.UICulture = startupConfiguration.UICulture;
-            _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode;
-            _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage;
+            _config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty;
+            _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty;
+            _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty;
             _config.SaveConfiguration();
             _config.SaveConfiguration();
             return NoContent();
             return NoContent();
         }
         }
@@ -89,9 +90,10 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto)
         public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto)
         {
         {
-            _config.Configuration.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess;
-            _config.Configuration.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping;
-            _config.SaveConfiguration();
+            NetworkConfiguration settings = _config.GetNetworkConfiguration();
+            settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess;
+            settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping;
+            _config.SaveConfiguration("network", settings);
             return NoContent();
             return NoContent();
         }
         }
 
 

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