Bladeren bron

Merge branch 'master' into library_scan_speed

BaronGreenback 4 jaren geleden
bovenliggende
commit
e8cb9cea7d
100 gewijzigde bestanden met toevoegingen van 6605 en 1034 verwijderingen
  1. 2 2
      .ci/azure-pipelines-test.yml
  2. 36 0
      .github/workflows/codeql-analysis.yml
  3. 1 1
      Emby.Server.Implementations/Channels/ChannelManager.cs
  4. 4 4
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  5. 9 62
      Emby.Server.Implementations/Devices/DeviceManager.cs
  6. 1 1
      Emby.Server.Implementations/Library/LibraryManager.cs
  7. 26 25
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
  8. 26 25
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs
  9. 6 1
      Emby.Server.Implementations/Localization/Core/el.json
  10. 4 1
      Emby.Server.Implementations/Localization/Core/hu.json
  11. 20 10
      Emby.Server.Implementations/Networking/NetworkManager.cs
  12. 3 3
      Emby.Server.Implementations/Playlists/PlaylistManager.cs
  13. 3 21
      Emby.Server.Implementations/Session/SessionManager.cs
  14. 84 14
      Emby.Server.Implementations/Updates/InstallationManager.cs
  15. 1 1
      Jellyfin.Api/Controllers/ApiKeyController.cs
  16. 53 61
      Jellyfin.Api/Controllers/ArtistsController.cs
  17. 166 3
      Jellyfin.Api/Controllers/AudioController.cs
  18. 1 1
      Jellyfin.Api/Controllers/BrandingController.cs
  19. 2 6
      Jellyfin.Api/Controllers/ChannelsController.cs
  20. 12 7
      Jellyfin.Api/Controllers/CollectionController.cs
  21. 1 1
      Jellyfin.Api/Controllers/DashboardController.cs
  22. 147 90
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  23. 23 20
      Jellyfin.Api/Controllers/FilterController.cs
  24. 6 6
      Jellyfin.Api/Controllers/GenresController.cs
  25. 21 7
      Jellyfin.Api/Controllers/HlsSegmentController.cs
  26. 686 56
      Jellyfin.Api/Controllers/ImageController.cs
  27. 3 3
      Jellyfin.Api/Controllers/InstantMixController.cs
  28. 1 1
      Jellyfin.Api/Controllers/ItemLookupController.cs
  29. 1 1
      Jellyfin.Api/Controllers/ItemUpdateController.cs
  30. 318 76
      Jellyfin.Api/Controllers/ItemsController.cs
  31. 6 7
      Jellyfin.Api/Controllers/LibraryController.cs
  32. 13 15
      Jellyfin.Api/Controllers/LiveTvController.cs
  33. 1 1
      Jellyfin.Api/Controllers/LocalizationController.cs
  34. 1 1
      Jellyfin.Api/Controllers/MediaInfoController.cs
  35. 1 1
      Jellyfin.Api/Controllers/MoviesController.cs
  36. 6 6
      Jellyfin.Api/Controllers/MusicGenresController.cs
  37. 1 1
      Jellyfin.Api/Controllers/PackageController.cs
  38. 5 5
      Jellyfin.Api/Controllers/PersonsController.cs
  39. 8 7
      Jellyfin.Api/Controllers/PlaylistsController.cs
  40. 3 2
      Jellyfin.Api/Controllers/PlaystateController.cs
  41. 1 1
      Jellyfin.Api/Controllers/PluginsController.cs
  42. 7 6
      Jellyfin.Api/Controllers/SearchController.cs
  43. 7 6
      Jellyfin.Api/Controllers/SessionController.cs
  44. 3 3
      Jellyfin.Api/Controllers/StartupController.cs
  45. 6 9
      Jellyfin.Api/Controllers/StudiosController.cs
  46. 40 2
      Jellyfin.Api/Controllers/SubtitleController.cs
  47. 6 5
      Jellyfin.Api/Controllers/SuggestionsController.cs
  48. 1 1
      Jellyfin.Api/Controllers/SyncPlayController.cs
  49. 1 1
      Jellyfin.Api/Controllers/SystemController.cs
  50. 1 1
      Jellyfin.Api/Controllers/TimeSyncController.cs
  51. 24 25
      Jellyfin.Api/Controllers/TrailersController.cs
  52. 10 7
      Jellyfin.Api/Controllers/UniversalAudioController.cs
  53. 1 1
      Jellyfin.Api/Controllers/UserController.cs
  54. 2 2
      Jellyfin.Api/Controllers/UserLibraryController.cs
  55. 5 4
      Jellyfin.Api/Controllers/UserViewsController.cs
  56. 2 1
      Jellyfin.Api/Controllers/VideoAttachmentsController.cs
  57. 131 40
      Jellyfin.Api/Controllers/VideoHlsController.cs
  58. 165 5
      Jellyfin.Api/Controllers/VideosController.cs
  59. 8 12
      Jellyfin.Api/Controllers/YearsController.cs
  60. 180 18
      Jellyfin.Api/Helpers/DynamicHlsHelper.cs
  61. 68 24
      Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
  62. 50 7
      Jellyfin.Api/Helpers/HlsHelpers.cs
  63. 0 43
      Jellyfin.Api/Helpers/RequestHelpers.cs
  64. 38 15
      Jellyfin.Api/Helpers/StreamingHelpers.cs
  65. 22 19
      Jellyfin.Api/Helpers/TranscodingJobHelper.cs
  66. 49 0
      Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs
  67. 47 0
      Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs
  68. 27 0
      Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs
  69. 90 0
      Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs
  70. 6 3
      Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
  71. 5 1
      Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
  72. 87 0
      Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs
  73. 0 44
      Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs
  74. 221 0
      Jellyfin.Networking/Configuration/NetworkConfiguration.cs
  75. 21 0
      Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs
  76. 27 0
      Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs
  77. 30 0
      Jellyfin.Networking/Jellyfin.Networking.csproj
  78. 234 0
      Jellyfin.Networking/Manager/INetworkManager.cs
  79. 1319 0
      Jellyfin.Networking/Manager/NetworkManager.cs
  80. 3 0
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  81. 2 2
      Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
  82. 8 6
      Jellyfin.Server/Startup.cs
  83. 2 1
      MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs
  84. 24 0
      MediaBrowser.Common/Json/Converters/JsonDateTimeIso8601Converter.cs
  85. 75 0
      MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs
  86. 28 0
      MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs
  87. 1 0
      MediaBrowser.Common/Json/JsonDefaults.cs
  88. 1 1
      MediaBrowser.Common/MediaBrowser.Common.csproj
  89. 445 0
      MediaBrowser.Common/Net/IPHost.cs
  90. 277 0
      MediaBrowser.Common/Net/IPNetAddress.cs
  91. 406 0
      MediaBrowser.Common/Net/IPObject.cs
  92. 262 0
      MediaBrowser.Common/Net/NetworkExtensions.cs
  93. 15 4
      MediaBrowser.Common/Plugins/BasePlugin.cs
  94. 5 2
      MediaBrowser.Common/Updates/IInstallationManager.cs
  95. 3 3
      MediaBrowser.Controller/Entities/Folder.cs
  96. 3 3
      MediaBrowser.Controller/Entities/InternalItemsQuery.cs
  97. 2 2
      MediaBrowser.Controller/Entities/UserViewBuilder.cs
  98. 378 147
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  99. 10 0
      MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
  100. 1 1
      MediaBrowser.Controller/Playlists/IPlaylistManager.cs

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

@@ -30,11 +30,11 @@ jobs:
 
       # This is required for the SonarCloud analyzer
       - task: UseDotNet@2
-        displayName: "Install .NET Core SDK 2.1"
+        displayName: "Install .NET SDK 5.x"
         condition: eq(variables['ImageName'], 'ubuntu-latest')
         inputs:
           packageType: sdk
-          version: '2.1.805'
+          version: '5.x'
 
       - task: UseDotNet@2
         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 - 1
Emby.Server.Implementations/Channels/ChannelManager.cs

@@ -634,7 +634,7 @@ namespace Emby.Server.Implementations.Channels
         {
             var channels = GetAllChannels().Where(i => i is ISupportsLatestMedia).ToArray();
 
-            if (query.ChannelIds.Length > 0)
+            if (query.ChannelIds.Count > 0)
             {
                 // Avoid implicitly captured closure
                 var ids = query.ChannelIds;

+ 4 - 4
Emby.Server.Implementations/Data/SqliteItemRepository.cs

@@ -3611,12 +3611,12 @@ namespace Emby.Server.Implementations.Data
                 whereClauses.Add($"type in ({inClause})");
             }
 
-            if (query.ChannelIds.Length == 1)
+            if (query.ChannelIds.Count == 1)
             {
                 whereClauses.Add("ChannelId=@ChannelId");
                 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) + "'"));
                 whereClauses.Add($"ChannelId in ({inClause})");
@@ -4076,7 +4076,7 @@ namespace Emby.Server.Implementations.Data
                 whereClauses.Add(clause);
             }
 
-            if (query.GenreIds.Length > 0)
+            if (query.GenreIds.Count > 0)
             {
                 var clauses = new List<string>();
                 var index = 0;
@@ -4097,7 +4097,7 @@ namespace Emby.Server.Implementations.Data
                 whereClauses.Add(clause);
             }
 
-            if (query.Genres.Length > 0)
+            if (query.Genres.Count > 0)
             {
                 var clauses = new List<string>();
                 var index = 0;

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

@@ -1,61 +1,38 @@
 #pragma warning disable CS1591
 
 using System;
+using System.Collections.Concurrent;
 using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
 using System.Linq;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Events;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Security;
 using MediaBrowser.Model.Devices;
 using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Session;
-using Microsoft.Extensions.Caching.Memory;
 
 namespace Emby.Server.Implementations.Devices
 {
     public class DeviceManager : IDeviceManager
     {
-        private readonly IMemoryCache _memoryCache;
-        private readonly IJsonSerializer _json;
         private readonly IUserManager _userManager;
-        private readonly IServerConfigurationManager _config;
         private readonly IAuthenticationRepository _authRepo;
-        private readonly 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;
-            _config = config;
-            _memoryCache = memoryCache;
             _authRepo = authRepo;
         }
 
+        public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
+
         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)
@@ -72,32 +49,12 @@ namespace Emby.Server.Implementations.Devices
 
         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)
-        {
-            return GetDevice(id, true);
-        }
-
-        private DeviceInfo GetDevice(string id, bool includeCapabilities)
         {
             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)
         {
             if (user == null)

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

@@ -1503,7 +1503,7 @@ namespace Emby.Server.Implementations.Library
         {
             if (query.AncestorIds.Length == 0 &&
                 query.ParentId.Equals(Guid.Empty) &&
-                query.ChannelIds.Length == 0 &&
+                query.ChannelIds.Count == 0 &&
                 query.TopParentIds.Length == 0 &&
                 string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
                 string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&

+ 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)
         {
-            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)
@@ -142,7 +142,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
         {
             _remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort);
 
-            _tcpClient = new TcpClient(_remoteEndPoint);
+            _tcpClient = new TcpClient();
+            _tcpClient.Connect(_remoteEndPoint);
 
             if (!_lockkey.HasValue)
             {
@@ -221,30 +222,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 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);
             }
         }
 

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

@@ -70,7 +70,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 try
                 {
                     await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort).ConfigureAwait(false);
-                    localAddress = ((IPEndPoint)tcpClient.Client.RemoteEndPoint).Address;
+                    localAddress = ((IPEndPoint)tcpClient.Client.LocalEndPoint).Address;
                     tcpClient.Close();
                 }
                 catch (Exception ex)
@@ -80,6 +80,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 }
             }
 
+            if (localAddress.IsIPv4MappedToIPv6) {
+                localAddress = localAddress.MapToIPv4();
+            }
+
             var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork);
             var hdHomerunManager = new HdHomerunManager();
 
@@ -110,12 +114,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
 
             var taskCompletionSource = new TaskCompletionSource<bool>();
 
-            await StartStreaming(
+            _ = StartStreaming(
                 udpClient,
                 hdHomerunManager,
                 remoteAddress,
                 taskCompletionSource,
-                LiveStreamCancellationTokenSource.Token).ConfigureAwait(false);
+                LiveStreamCancellationTokenSource.Token);
 
             // OpenedMediaSource.Protocol = MediaProtocol.File;
             // OpenedMediaSource.Path = tempFile;
@@ -136,33 +140,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             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);
+                }
+
+                EnableStreamSharing = false;
+            }
 
-                await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false);
-            });
+            await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false);
         }
 
         private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)

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

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

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

@@ -115,5 +115,8 @@
     "TaskRefreshChannels": "Csatornák frissítése",
     "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.",
-    "TaskCleanActivityLog": "Tevékenységnapló törlése"
+    "TaskCleanActivityLog": "Tevékenységnapló törlése",
+    "Undefined": "Meghatározatlan",
+    "Forced": "Kényszerített",
+    "Default": "Alapértelmezett"
 }

+ 20 - 10
Emby.Server.Implementations/Networking/NetworkManager.cs

@@ -18,13 +18,12 @@ namespace Emby.Server.Implementations.Networking
     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 IPAddress[] _localIpAddresses;
+
         private List<PhysicalAddress> _macAddresses;
 
         /// <summary>
@@ -157,7 +156,9 @@ namespace Emby.Server.Implementations.Networking
                 return false;
             }
 
-            byte[] octet = ipAddress.GetAddressBytes();
+            // GetAddressBytes
+            Span<byte> octet = stackalloc byte[ipAddress.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
+            ipAddress.TryWriteBytes(octet, out _);
 
             if ((octet[0] == 10) ||
                 (octet[0] == 172 && (octet[1] >= 16 && octet[1] <= 31)) || // RFC1918
@@ -260,7 +261,9 @@ namespace Emby.Server.Implementations.Networking
         /// <inheritdoc/>
         public bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC)
         {
-            byte[] octet = address.GetAddressBytes();
+            // GetAddressBytes
+            Span<byte> octet = stackalloc byte[address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
+            address.TryWriteBytes(octet, out _);
 
             if ((octet[0] == 127) || // RFC1122
                 (octet[0] == 169 && octet[1] == 254)) // RFC3927
@@ -503,18 +506,25 @@ namespace Emby.Server.Implementations.Networking
 
         private IPAddress GetNetworkAddress(IPAddress address, IPAddress subnetMask)
         {
-            byte[] ipAdressBytes = address.GetAddressBytes();
-            byte[] subnetMaskBytes = subnetMask.GetAddressBytes();
+            int size = address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16;
+
+            // GetAddressBytes
+            Span<byte> ipAddressBytes = stackalloc byte[size];
+            address.TryWriteBytes(ipAddressBytes, out _);
+
+            // GetAddressBytes
+            Span<byte> subnetMaskBytes = stackalloc byte[size];
+            subnetMask.TryWriteBytes(subnetMaskBytes, out _);
 
-            if (ipAdressBytes.Length != subnetMaskBytes.Length)
+            if (ipAddressBytes.Length != subnetMaskBytes.Length)
             {
                 throw new ArgumentException("Lengths of IP address and subnet mask do not match.");
             }
 
-            byte[] broadcastAddress = new byte[ipAdressBytes.Length];
+            byte[] broadcastAddress = new byte[ipAddressBytes.Length];
             for (int i = 0; i < broadcastAddress.Length; i++)
             {
-                broadcastAddress[i] = (byte)(ipAdressBytes[i] & subnetMaskBytes[i]);
+                broadcastAddress[i] = (byte)(ipAddressBytes[i] & subnetMaskBytes[i]);
             }
 
             return new IPAddress(broadcastAddress);

+ 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)
                     .ConfigureAwait(false);
 
-                if (options.ItemIdList.Length > 0)
+                if (options.ItemIdList.Count > 0)
                 {
                     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);
         }
 
-        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);
 
@@ -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
             var playlist = _libraryManager.GetItemById(playlistId) as Playlist

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

@@ -58,8 +58,7 @@ namespace Emby.Server.Implementations.Session
         /// <summary>
         /// The active connections.
         /// </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;
 
@@ -196,7 +195,7 @@ namespace Emby.Server.Implementations.Session
         {
             if (!string.IsNullOrEmpty(info.DeviceId))
             {
-                var capabilities = GetSavedCapabilities(info.DeviceId);
+                var capabilities = _deviceManager.GetCapabilities(info.DeviceId);
 
                 if (capabilities != null)
                 {
@@ -1677,27 +1676,10 @@ namespace Emby.Server.Implementations.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>
         /// Converts a BaseItem to a BaseItemInfo.
         /// </summary>

+ 84 - 14
Emby.Server.Implementations/Updates/InstallationManager.cs

@@ -93,17 +93,29 @@ namespace Emby.Server.Implementations.Updates
         public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal;
 
         /// <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
             {
                 using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
-                    .GetAsync(manifest, cancellationToken).ConfigureAwait(false);
+                    .GetAsync(new Uri(manifest), cancellationToken).ConfigureAwait(false);
                 await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 
                 try
                 {
-                    return await _jsonSerializer.DeserializeFromStreamAsync<IReadOnlyList<PackageInfo>>(stream).ConfigureAwait(false);
+                    var package = await _jsonSerializer.DeserializeFromStreamAsync<IList<PackageInfo>>(stream).ConfigureAwait(false);
+
+                    // Store the repository and repository url with each version, as they may be spread apart.
+                    foreach (var entry in package)
+                    {
+                        foreach (var ver in entry.versions)
+                        {
+                            ver.repositoryName = manifestName;
+                            ver.repositoryUrl = manifest;
+                        }
+                    }
+
+                    return package;
                 }
                 catch (SerializationException ex)
                 {
@@ -123,17 +135,69 @@ 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 />
         public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default)
         {
             var result = new List<PackageInfo>();
             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))
+                    {
+                        var existing = FilterPackages(result, package.name, Guid.Parse(package.guid)).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 +208,8 @@ namespace Emby.Server.Implementations.Updates
         public IEnumerable<PackageInfo> FilterPackages(
             IEnumerable<PackageInfo> availablePackages,
             string name = null,
-            Guid guid = default)
+            Guid guid = default,
+            Version specificVersion = null)
         {
             if (name != null)
             {
@@ -156,6 +221,11 @@ namespace Emby.Server.Implementations.Updates
                 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;
         }
 
@@ -167,7 +237,7 @@ namespace Emby.Server.Implementations.Updates
             Version minVersion = null,
             Version specificVersion = null)
         {
-            var package = FilterPackages(availablePackages, name, guid).FirstOrDefault();
+            var package = FilterPackages(availablePackages, name, guid, specificVersion).FirstOrDefault();
 
             // Package not found in repository
             if (package == null)
@@ -181,21 +251,21 @@ namespace Emby.Server.Implementations.Updates
 
             if (specificVersion != null)
             {
-                availableVersions = availableVersions.Where(x => new Version(x.version) == specificVersion);
+                availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion));
             }
             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
                 {
                     Changelog = v.changelog,
                     Guid = new Guid(package.guid),
                     Name = package.name,
-                    Version = new Version(v.version),
+                    Version = v.VersionNumber,
                     SourceUrl = v.sourceUrl,
                     Checksum = v.checksum
                 };
@@ -333,7 +403,7 @@ namespace Emby.Server.Implementations.Updates
             string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
 
             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);
 
             // CA5351: Do Not Use Broken Cryptographic Algorithms

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

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

+ 53 - 61
Jellyfin.Api/Controllers/ArtistsController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Constants;
@@ -89,24 +89,24 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [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] 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] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [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] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
@@ -131,30 +131,26 @@ namespace Jellyfin.Api.Controllers
                 parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
             }
 
-            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
-            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
-            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
-
             var query = new InternalItemsQuery(user)
             {
-                ExcludeItemTypes = excludeItemTypesArr,
-                IncludeItemTypes = includeItemTypesArr,
-                MediaTypes = mediaTypesArr,
+                ExcludeItemTypes = excludeItemTypes,
+                IncludeItemTypes = includeItemTypes,
+                MediaTypes = mediaTypes,
                 StartIndex = startIndex,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, '|', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds),
-                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Tags = tags,
+                OfficialRatings = officialRatings,
+                Genres = genres,
+                GenreIds = genreIds,
+                StudioIds = studioIds,
                 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,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
@@ -174,9 +170,9 @@ namespace Jellyfin.Api.Controllers
             }
 
             // Studios
-            if (!string.IsNullOrEmpty(studios))
+            if (studios.Length != 0)
             {
-                query.StudioIds = studios.Split('|').Select(i =>
+                query.StudioIds = studios.Select(i =>
                 {
                     try
                     {
@@ -230,7 +226,7 @@ namespace Jellyfin.Api.Controllers
                 var (baseItem, itemCounts) = i;
                 var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
 
-                if (!string.IsNullOrWhiteSpace(includeItemTypes))
+                if (includeItemTypes.Length != 0)
                 {
                     dto.ChildCount = itemCounts.ItemCount;
                     dto.ProgramCount = itemCounts.ProgramCount;
@@ -297,24 +293,24 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [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] 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] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [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] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
@@ -339,30 +335,26 @@ namespace Jellyfin.Api.Controllers
                 parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
             }
 
-            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
-            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
-            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
-
             var query = new InternalItemsQuery(user)
             {
-                ExcludeItemTypes = excludeItemTypesArr,
-                IncludeItemTypes = includeItemTypesArr,
-                MediaTypes = mediaTypesArr,
+                ExcludeItemTypes = excludeItemTypes,
+                IncludeItemTypes = includeItemTypes,
+                MediaTypes = mediaTypes,
                 StartIndex = startIndex,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, '|', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds),
-                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Tags = tags,
+                OfficialRatings = officialRatings,
+                Genres = genres,
+                GenreIds = genreIds,
+                StudioIds = studioIds,
                 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,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
@@ -382,9 +374,9 @@ namespace Jellyfin.Api.Controllers
             }
 
             // Studios
-            if (!string.IsNullOrEmpty(studios))
+            if (studios.Length != 0)
             {
-                query.StudioIds = studios.Split('|').Select(i =>
+                query.StudioIds = studios.Select(i =>
                 {
                     try
                     {
@@ -438,7 +430,7 @@ namespace Jellyfin.Api.Controllers
                 var (baseItem, itemCounts) = i;
                 var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
 
-                if (!string.IsNullOrWhiteSpace(includeItemTypes))
+                if (includeItemTypes.Length != 0)
                 {
                     dto.ChildCount = itemCounts.ItemCount;
                     dto.ProgramCount = itemCounts.ProgramCount;

+ 166 - 3
Jellyfin.Api/Controllers/AudioController.cs

@@ -85,15 +85,178 @@ namespace Jellyfin.Api.Controllers
         /// <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:required}", Name = "GetAudioStreamByContainer")]
         [HttpGet("{itemId}/stream", Name = "GetAudioStream")]
-        [HttpHead("{itemId}/stream.{container:required}", Name = "HeadAudioStreamByContainer")]
         [HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesAudioFile]
         public async Task<ActionResult> GetAudioStream(
             [FromRoute, Required] Guid itemId,
-            [FromRoute] string? container,
+            [FromQuery] string? container,
+            [FromQuery] bool? @static,
+            [FromQuery] string? @params,
+            [FromQuery] string? tag,
+            [FromQuery] string? deviceProfileId,
+            [FromQuery] string? playSessionId,
+            [FromQuery] string? segmentContainer,
+            [FromQuery] int? segmentLength,
+            [FromQuery] int? minSegments,
+            [FromQuery] string? mediaSourceId,
+            [FromQuery] string? deviceId,
+            [FromQuery] string? audioCodec,
+            [FromQuery] bool? enableAutoStreamCopy,
+            [FromQuery] bool? allowVideoStreamCopy,
+            [FromQuery] bool? allowAudioStreamCopy,
+            [FromQuery] bool? breakOnNonKeyFrames,
+            [FromQuery] int? audioSampleRate,
+            [FromQuery] int? maxAudioBitDepth,
+            [FromQuery] int? audioBitRate,
+            [FromQuery] int? audioChannels,
+            [FromQuery] int? maxAudioChannels,
+            [FromQuery] string? profile,
+            [FromQuery] string? level,
+            [FromQuery] float? framerate,
+            [FromQuery] float? maxFramerate,
+            [FromQuery] bool? copyTimestamps,
+            [FromQuery] long? startTimeTicks,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? videoBitRate,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] SubtitleDeliveryMethod subtitleMethod,
+            [FromQuery] int? maxRefFrames,
+            [FromQuery] int? maxVideoBitDepth,
+            [FromQuery] bool? requireAvc,
+            [FromQuery] bool? deInterlace,
+            [FromQuery] bool? requireNonAnamorphic,
+            [FromQuery] int? transcodingMaxAudioChannels,
+            [FromQuery] int? cpuCoreLimit,
+            [FromQuery] string? liveStreamId,
+            [FromQuery] bool? enableMpegtsM2TsMode,
+            [FromQuery] string? videoCodec,
+            [FromQuery] string? subtitleCodec,
+            [FromQuery] string? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext? context,
+            [FromQuery] Dictionary<string, string>? streamOptions)
+        {
+            StreamingRequestDto streamingRequest = new StreamingRequestDto
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static ?? true,
+                Params = @params,
+                Tag = tag,
+                DeviceProfileId = deviceProfileId,
+                PlaySessionId = playSessionId,
+                SegmentContainer = segmentContainer,
+                SegmentLength = segmentLength,
+                MinSegments = minSegments,
+                MediaSourceId = mediaSourceId,
+                DeviceId = deviceId,
+                AudioCodec = audioCodec,
+                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+                AudioSampleRate = audioSampleRate,
+                MaxAudioChannels = maxAudioChannels,
+                AudioBitRate = audioBitRate,
+                MaxAudioBitDepth = maxAudioBitDepth,
+                AudioChannels = audioChannels,
+                Profile = profile,
+                Level = level,
+                Framerate = framerate,
+                MaxFramerate = maxFramerate,
+                CopyTimestamps = copyTimestamps ?? true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc ?? true,
+                DeInterlace = deInterlace ?? true,
+                RequireNonAnamorphic = requireNonAnamorphic ?? true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context ?? EncodingContext.Static,
+                StreamOptions = streamOptions
+            };
+
+            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="transcodingReasons">Optional. The transcoding reason.</param>
+        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+        /// <param name="streamOptions">Optional. The streaming options.</param>
+        /// <response code="200">Audio stream returned.</response>
+        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+        [HttpGet("{itemId}/stream.{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,

+ 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.Model.Branding;
 using Microsoft.AspNetCore.Http;

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

@@ -198,7 +198,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? channelIds)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds)
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
@@ -208,11 +208,7 @@ namespace Jellyfin.Api.Controllers
             {
                 Limit = limit,
                 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 }
             };
 

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

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

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

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

+ 147 - 90
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis;
 using System.Globalization;
 using System.IO;
 using System.Linq;
+using System.Runtime.InteropServices;
 using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
@@ -41,6 +42,9 @@ namespace Jellyfin.Api.Controllers
     [Authorize(Policy = Policies.DefaultAuthorization)]
     public class DynamicHlsController : BaseJellyfinApiController
     {
+        private const string DefaultEncoderPreset = "veryfast";
+        private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
+
         private readonly ILibraryManager _libraryManager;
         private readonly IUserManager _userManager;
         private readonly IDlnaManager _dlnaManager;
@@ -56,8 +60,7 @@ namespace Jellyfin.Api.Controllers
         private readonly ILogger<DynamicHlsController> _logger;
         private readonly EncodingHelper _encodingHelper;
         private readonly DynamicHlsHelper _dynamicHlsHelper;
-
-        private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
+        private readonly EncodingOptions _encodingOptions;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
@@ -92,6 +95,8 @@ namespace Jellyfin.Api.Controllers
             ILogger<DynamicHlsController> logger,
             DynamicHlsHelper dynamicHlsHelper)
         {
+            _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
+
             _libraryManager = libraryManager;
             _userManager = userManager;
             _dlnaManager = dlnaManager;
@@ -106,8 +111,7 @@ namespace Jellyfin.Api.Controllers
             _transcodingJobHelper = transcodingJobHelper;
             _logger = logger;
             _dynamicHlsHelper = dynamicHlsHelper;
-
-            _encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
+            _encodingOptions = serverConfigurationManager.GetEncodingOptions();
         }
 
         /// <summary>
@@ -272,7 +276,7 @@ namespace Jellyfin.Api.Controllers
                 EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
             };
 
-            return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+            return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
         }
 
         /// <summary>
@@ -439,7 +443,7 @@ namespace Jellyfin.Api.Controllers
                 EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
             };
 
-            return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+            return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
         }
 
         /// <summary>
@@ -834,7 +838,7 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] string playlistId,
             [FromRoute, Required] int segmentId,
-            [FromRoute] string container,
+            [FromRoute, Required] string container,
             [FromQuery] bool? @static,
             [FromQuery] string? @params,
             [FromQuery] string? tag,
@@ -1005,7 +1009,7 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] string playlistId,
             [FromRoute, Required] int segmentId,
-            [FromRoute] string container,
+            [FromRoute, Required] string container,
             [FromQuery] bool? @static,
             [FromQuery] string? @params,
             [FromQuery] string? tag,
@@ -1129,7 +1133,7 @@ namespace Jellyfin.Api.Controllers
                     _dlnaManager,
                     _deviceManager,
                     _transcodingJobHelper,
-                    _transcodingJobType,
+                    TranscodingJobType,
                     cancellationTokenSource.Token)
                 .ConfigureAwait(false);
 
@@ -1137,11 +1141,19 @@ namespace Jellyfin.Api.Controllers
 
             var segmentLengths = GetSegmentLengths(state);
 
+            var segmentContainer = state.Request.SegmentContainer ?? "ts";
+
+            // http://ffmpeg.org/ffmpeg-all.html#toc-hls-2
+            var isHlsInFmp4 = string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase);
+            var hlsVersion = isHlsInFmp4 ? "7" : "3";
+
             var builder = new StringBuilder();
 
             builder.AppendLine("#EXTM3U")
                 .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
-                .AppendLine("#EXT-X-VERSION:3")
+                .Append("#EXT-X-VERSION:")
+                .Append(hlsVersion)
+                .AppendLine()
                 .Append("#EXT-X-TARGETDURATION:")
                 .Append(Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength))
                 .AppendLine()
@@ -1151,6 +1163,18 @@ namespace Jellyfin.Api.Controllers
             var segmentExtension = GetSegmentFileExtension(streamingRequest.SegmentContainer);
             var queryString = Request.QueryString;
 
+            if (isHlsInFmp4)
+            {
+                builder.Append("#EXT-X-MAP:URI=\"")
+                    .Append("hls1/")
+                    .Append(name)
+                    .Append("/-1")
+                    .Append(segmentExtension)
+                    .Append(queryString)
+                    .Append('"')
+                    .AppendLine();
+            }
+
             foreach (var length in segmentLengths)
             {
                 builder.Append("#EXTINF:")
@@ -1194,7 +1218,7 @@ namespace Jellyfin.Api.Controllers
                     _dlnaManager,
                     _deviceManager,
                     _transcodingJobHelper,
-                    _transcodingJobType,
+                    TranscodingJobType,
                     cancellationTokenSource.Token)
                 .ConfigureAwait(false);
 
@@ -1208,7 +1232,7 @@ namespace Jellyfin.Api.Controllers
 
             if (System.IO.File.Exists(segmentPath))
             {
-                job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType);
+                job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
                 _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath);
                 return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
             }
@@ -1222,7 +1246,7 @@ namespace Jellyfin.Api.Controllers
             {
                 if (System.IO.File.Exists(segmentPath))
                 {
-                    job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType);
+                    job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
                     transcodingLock.Release();
                     released = true;
                     _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath);
@@ -1233,7 +1257,13 @@ namespace Jellyfin.Api.Controllers
                     var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
                     var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength;
 
-                    if (currentTranscodingIndex == null)
+                    if (segmentId == -1)
+                    {
+                        _logger.LogDebug("Starting transcoding because fmp4 init file is being requested");
+                        startTranscoding = true;
+                        segmentId = 0;
+                    }
+                    else if (currentTranscodingIndex == null)
                     {
                         _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null");
                         startTranscoding = true;
@@ -1265,13 +1295,12 @@ namespace Jellyfin.Api.Controllers
                             streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId);
 
                             state.WaitForPath = segmentPath;
-                            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
                             job = await _transcodingJobHelper.StartFfMpeg(
                                 state,
                                 playlistPath,
-                                GetCommandLineArguments(playlistPath, encodingOptions, state, true, segmentId),
+                                GetCommandLineArguments(playlistPath, state, true, segmentId),
                                 Request,
-                                _transcodingJobType,
+                                TranscodingJobType,
                                 cancellationTokenSource).ConfigureAwait(false);
                         }
                         catch
@@ -1284,7 +1313,7 @@ namespace Jellyfin.Api.Controllers
                     }
                     else
                     {
-                        job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType);
+                        job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
                         if (job?.TranscodingThrottler != null)
                         {
                             await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
@@ -1301,7 +1330,7 @@ namespace Jellyfin.Api.Controllers
             }
 
             _logger.LogDebug("returning {0} [general case]", segmentPath);
-            job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType);
+            job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
             return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
         }
 
@@ -1325,11 +1354,10 @@ namespace Jellyfin.Api.Controllers
             return result.ToArray();
         }
 
-        private string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding, int startNumber)
+        private string GetCommandLineArguments(string outputPath, StreamState state, bool isEncoding, int startNumber)
         {
-            var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
-
-            var threads = EncodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec); // GetNumberOfThreads is static.
+            var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+            var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
 
             if (state.BaseRequest.BreakOnNonKeyFrames)
             {
@@ -1341,36 +1369,57 @@ namespace Jellyfin.Api.Controllers
                 state.BaseRequest.BreakOnNonKeyFrames = false;
             }
 
-            var inputModifier = _encodingHelper.GetInputModifier(state, encodingOptions);
-
             // If isEncoding is true we're actually starting ffmpeg
             var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0";
-
+            var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
             var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
 
             var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
+            var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+            var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
+            var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer);
+            var outputTsArg = outputPrefix + "%d" + outputExtension;
 
-            var outputTsArg = Path.Combine(directory, Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request.SegmentContainer);
-
-            var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
+            var segmentFormat = outputExtension.TrimStart('.');
             if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
             {
                 segmentFormat = "mpegts";
             }
+            else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
+            {
+                var outputFmp4HeaderArg = string.Empty;
+                var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+                if (isWindows)
+                {
+                    // on Windows, the path of fmp4 header file needs to be configured
+                    outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"";
+                }
+                else
+                {
+                    // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
+                    outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"";
+                }
 
-            var maxMuxingQueueSize = encodingOptions.MaxMuxingQueueSize > 128
-                ? encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
+                segmentFormat = "fmp4" + outputFmp4HeaderArg;
+            }
+            else
+            {
+                _logger.LogError("Invalid HLS segment container: " + segmentFormat);
+            }
+
+            var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
+                ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
                 : "128";
 
             return string.Format(
                 CultureInfo.InvariantCulture,
-                "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -individual_header_trailer 0 -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"",
+                "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"",
                 inputModifier,
-                _encodingHelper.GetInputArgument(state, encodingOptions),
+                _encodingHelper.GetInputArgument(state, _encodingOptions),
                 threads,
                 mapArgs,
-                GetVideoArguments(state, encodingOptions, startNumber),
-                GetAudioArguments(state, encodingOptions),
+                GetVideoArguments(state, startNumber),
+                GetAudioArguments(state),
                 maxMuxingQueueSize,
                 state.SegmentLength.ToString(CultureInfo.InvariantCulture),
                 segmentFormat,
@@ -1379,50 +1428,63 @@ namespace Jellyfin.Api.Controllers
                 outputPath).Trim();
         }
 
-        private string GetAudioArguments(StreamState state, EncodingOptions encodingOptions)
+        /// <summary>
+        /// Gets the audio arguments for transcoding.
+        /// </summary>
+        /// <param name="state">The <see cref="StreamState"/>.</param>
+        /// <returns>The command line arguments for audio transcoding.</returns>
+        private string GetAudioArguments(StreamState state)
         {
+            if (state.AudioStream == null)
+            {
+                return string.Empty;
+            }
+
             var audioCodec = _encodingHelper.GetAudioEncoder(state);
 
             if (!state.IsOutputVideo)
             {
                 if (EncodingHelper.IsCopyCodec(audioCodec))
                 {
-                    return "-acodec copy";
+                    var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
+
+                    return "-acodec copy -strict -2" + bitStreamArgs;
                 }
 
-                var audioTranscodeParams = new List<string>();
+                var audioTranscodeParams = string.Empty;
 
-                audioTranscodeParams.Add("-acodec " + audioCodec);
+                audioTranscodeParams += "-acodec " + audioCodec;
 
                 if (state.OutputAudioBitrate.HasValue)
                 {
-                    audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture));
+                    audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture);
                 }
 
                 if (state.OutputAudioChannels.HasValue)
                 {
-                    audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture));
+                    audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture);
                 }
 
                 if (state.OutputAudioSampleRate.HasValue)
                 {
-                    audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture));
+                    audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
                 }
 
-                audioTranscodeParams.Add("-vn");
-                return string.Join(' ', audioTranscodeParams);
+                audioTranscodeParams += " -vn";
+                return audioTranscodeParams;
             }
 
             if (EncodingHelper.IsCopyCodec(audioCodec))
             {
-                var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
+                var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+                var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
 
                 if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
                 {
-                    return "-codec:a:0 copy -copypriorss:a:0 0";
+                    return "-codec:a:0 copy -strict -2 -copypriorss:a:0 0" + bitStreamArgs;
                 }
 
-                return "-codec:a:0 copy";
+                return "-codec:a:0 copy -strict -2" + bitStreamArgs;
             }
 
             var args = "-codec:a:0 " + audioCodec;
@@ -1446,94 +1508,89 @@ namespace Jellyfin.Api.Controllers
                 args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
             }
 
-            args += " " + _encodingHelper.GetAudioFilterParam(state, encodingOptions, true);
+            args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
 
             return args;
         }
 
-        private string GetVideoArguments(StreamState state, EncodingOptions encodingOptions, int startNumber)
+        /// <summary>
+        /// Gets the video arguments for transcoding.
+        /// </summary>
+        /// <param name="state">The <see cref="StreamState"/>.</param>
+        /// <param name="startNumber">The first number in the hls sequence.</param>
+        /// <returns>The command line arguments for video transcoding.</returns>
+        private string GetVideoArguments(StreamState state, int startNumber)
         {
+            if (state.VideoStream == null)
+            {
+                return string.Empty;
+            }
+
             if (!state.IsOutputVideo)
             {
                 return string.Empty;
             }
 
-            var codec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
+            var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
 
             var args = "-codec:v:0 " + codec;
 
+            // Prefer hvc1 to hev1.
+            if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+            {
+                args += " -tag:v:0 hvc1";
+            }
+
             // if  (state.EnableMpegtsM2TsMode)
             // {
             //     args += " -mpegts_m2ts_mode 1";
             // }
 
-            // See if we can save come cpu cycles by avoiding encoding
+            // See if we can save come cpu cycles by avoiding encoding.
             if (EncodingHelper.IsCopyCodec(codec))
             {
                 if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
                 {
-                    string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream);
+                    string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream);
                     if (!string.IsNullOrEmpty(bitStreamArgs))
                     {
                         args += " " + bitStreamArgs;
                     }
                 }
 
+                args += " -start_at_zero";
+
                 // args += " -flags -global_header";
             }
             else
             {
-                var gopArg = string.Empty;
-                var keyFrameArg = string.Format(
-                    CultureInfo.InvariantCulture,
-                    " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"",
-                    startNumber * state.SegmentLength,
-                    state.SegmentLength);
-
-                var framerate = state.VideoStream?.RealFrameRate;
+                args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset);
 
-                if (framerate.HasValue)
-                {
-                    // This is to make sure keyframe interval is limited to our segment,
-                    // as forcing keyframes is not enough.
-                    // Example: we encoded half of desired length, then codec detected
-                    // scene cut and inserted a keyframe; next forced keyframe would
-                    // be created outside of segment, which breaks seeking
-                    // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe
-                    gopArg = string.Format(
-                        CultureInfo.InvariantCulture,
-                        " -g {0} -keyint_min {0} -sc_threshold 0",
-                        Math.Ceiling(state.SegmentLength * framerate.Value));
-                }
+                // Set the key frame params for video encoding to match the hls segment time.
+                args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, false, startNumber);
 
-                args += " " + _encodingHelper.GetVideoQualityParam(state, codec, encodingOptions, "veryfast");
-
-                // Unable to force key frames using these hw encoders, set key frames by GOP
-                if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase))
+                // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
+                if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
                 {
-                    args += " " + gopArg;
-                }
-                else
-                {
-                    args += " " + keyFrameArg + gopArg;
+                    args += " -bf 0";
                 }
 
                 // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0";
 
                 var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
 
-                // This is for graphical subs
                 if (hasGraphicalSubs)
                 {
-                    args += _encodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec);
+                    // Graphical subs overlay and resolution params.
+                    args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
                 }
-
-                // Add resolution params, if specified
                 else
                 {
-                    args += _encodingHelper.GetOutputSizeParam(state, encodingOptions, codec);
+                    // Resolution params.
+                    args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
                 }
 
                 // -start_at_zero is necessary to use with -ss when seeking,
@@ -1693,7 +1750,7 @@ namespace Jellyfin.Api.Controllers
 
         private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension)
         {
-            var job = _transcodingJobHelper.GetTranscodingJob(playlist, _transcodingJobType);
+            var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType);
 
             if (job == null || job.HasExited)
             {

+ 23 - 20
Jellyfin.Api/Controllers/FilterController.cs

@@ -1,6 +1,7 @@
-using System;
+using System;
 using System.Linq;
 using Jellyfin.Api.Constants;
+using Jellyfin.Api.ModelBinders;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
@@ -50,8 +51,8 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
             [FromQuery] Guid? userId,
             [FromQuery] string? parentId,
-            [FromQuery] string? includeItemTypes,
-            [FromQuery] string? mediaTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes)
         {
             var parentItem = string.IsNullOrEmpty(parentId)
                 ? null
@@ -61,10 +62,11 @@ namespace Jellyfin.Api.Controllers
                 ? _userManager.GetUserById(userId.Value)
                 : null;
 
-            if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+            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;
             }
@@ -78,8 +80,8 @@ namespace Jellyfin.Api.Controllers
             var query = new InternalItemsQuery
             {
                 User = user,
-                MediaTypes = (mediaTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
-                IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
+                MediaTypes = mediaTypes,
+                IncludeItemTypes = includeItemTypes,
                 Recursive = true,
                 EnableTotalRecordCount = false,
                 DtoOptions = new DtoOptions
@@ -139,7 +141,7 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<QueryFilters> GetQueryFilters(
             [FromQuery] Guid? userId,
             [FromQuery] string? parentId,
-            [FromQuery] string? includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
             [FromQuery] bool? isAiring,
             [FromQuery] bool? isMovie,
             [FromQuery] bool? isSports,
@@ -156,10 +158,11 @@ namespace Jellyfin.Api.Controllers
                 ? _userManager.GetUserById(userId.Value)
                 : null;
 
-            if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
-                || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+            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;
             }
@@ -167,8 +170,7 @@ namespace Jellyfin.Api.Controllers
             var filters = new QueryFilters();
             var genreQuery = new InternalItemsQuery(user)
             {
-                IncludeItemTypes =
-                    (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
+                IncludeItemTypes = includeItemTypes,
                 DtoOptions = new DtoOptions
                 {
                     Fields = Array.Empty<ItemFields>(),
@@ -192,10 +194,11 @@ namespace Jellyfin.Api.Controllers
                 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
                 {

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Constants;
@@ -74,8 +74,8 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [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] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers
 
             var query = new InternalItemsQuery(user)
             {
-                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
-                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                ExcludeItemTypes = excludeItemTypes,
+                IncludeItemTypes = includeItemTypes,
                 StartIndex = startIndex,
                 Limit = limit,
                 IsFavorite = isFavorite,
@@ -133,7 +133,7 @@ namespace Jellyfin.Api.Controllers
                 result = _libraryManager.GetGenres(query);
             }
 
-            var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
+            var shouldIncludeItemTypes = includeItemTypes.Length != 0;
             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.Diagnostics.CodeAnalysis;
 using System.IO;
@@ -112,11 +112,13 @@ namespace Jellyfin.Api.Controllers
         /// <param name="segmentId">The segment id.</param>
         /// <param name="segmentContainer">The segment container.</param>
         /// <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>
         // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
         // [Authenticated]
         [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesVideoFile]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
         public ActionResult GetHlsVideoSegmentLegacy(
@@ -132,13 +134,25 @@ namespace Jellyfin.Api.Controllers
 
             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)

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 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>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
         [HttpPost("Users/{userId}/Images/{imageType}")]
-        [HttpPost("Users/{userId}/Images/{imageType}/{index?}", Name = "PostUserImage_2")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
@@ -95,7 +94,53 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult> PostUserImage(
             [FromRoute, Required] Guid userId,
             [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))
             {
@@ -132,8 +177,7 @@ namespace Jellyfin.Api.Controllers
         /// <response code="204">Image deleted.</response>
         /// <response code="403">User does not have permission to delete the image.</response>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Users/{userId}/Images/{itemType}")]
-        [HttpDelete("Users/{userId}/Images/{itemType}/{index?}", Name = "DeleteUserImage_2")]
+        [HttpDelete("Users/{userId}/Images/{imageType}")]
         [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")]
@@ -142,7 +186,46 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult> DeleteUserImage(
             [FromRoute, Required] Guid userId,
             [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))
             {
@@ -173,14 +256,13 @@ namespace Jellyfin.Api.Controllers
         /// <response code="404">Item not found.</response>
         /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
         [HttpDelete("Items/{itemId}/Images/{imageType}")]
-        [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "DeleteItemImage_2")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult> DeleteItemImage(
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] ImageType imageType,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
         {
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)
@@ -192,25 +274,83 @@ namespace Jellyfin.Api.Controllers
             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>
         /// Set item image.
         /// </summary>
         /// <param name="itemId">Item id.</param>
         /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">(Unused) Image index.</param>
         /// <response code="204">Image saved.</response>
         /// <response code="404">Item not found.</response>
         /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
         [HttpPost("Items/{itemId}/Images/{imageType}")]
-        [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "SetItemImage_2")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
         public async Task<ActionResult> SetItemImage(
+            [FromRoute, 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] ImageType imageType,
-            [FromRoute] int? imageIndex = null)
+            [FromRoute] int imageIndex)
         {
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)
@@ -350,8 +490,6 @@ namespace Jellyfin.Api.Controllers
         /// </returns>
         [HttpGet("Items/{itemId}/Images/{imageType}")]
         [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")]
-        [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "GetItemImage_2")]
-        [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
@@ -372,7 +510,86 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [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);
             if (item == null)
@@ -508,8 +725,8 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadArtistImage")]
+        [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
@@ -587,8 +804,8 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadGenreImage")]
+        [HttpGet("Genres/{name}/Images/{imageType}")]
+        [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
@@ -609,7 +826,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
         {
             var item = _libraryManager.GetGenre(name);
             if (item == null)
@@ -641,10 +858,11 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Get music genre image by name.
+        /// Get genre image by name.
         /// </summary>
-        /// <param name="name">Music genre name.</param>
+        /// <param name="name">Genre 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>
@@ -659,21 +877,21 @@ namespace Jellyfin.Api.Controllers
         /// <param name="blur">Optional. Blur image.</param>
         /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
         /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
         /// <response code="200">Image stream returned.</response>
         /// <response code="404">Item not found.</response>
         /// <returns>
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadMusicGenreImage")]
+        [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
-        public async Task<ActionResult> GetMusicGenreImage(
+        public async Task<ActionResult> GetGenreImageByIndex(
             [FromRoute, Required] string name,
             [FromRoute, Required] ImageType imageType,
+            [FromRoute, Required] int imageIndex,
             [FromQuery] string tag,
             [FromQuery] ImageFormat? format,
             [FromQuery] int? maxWidth,
@@ -687,10 +905,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? addPlayedIndicator,
             [FromQuery] int? blur,
             [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)
             {
                 return NotFound();
@@ -720,9 +937,9 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Get person image by name.
+        /// Get music genre image by name.
         /// </summary>
-        /// <param name="name">Person name.</param>
+        /// <param name="name">Music genre name.</param>
         /// <param name="imageType">Image type.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
@@ -745,12 +962,12 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadPersonImage")]
+        [HttpGet("MusicGenres/{name}/Images/{imageType}")]
+        [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
-        public async Task<ActionResult> GetPersonImage(
+        public async Task<ActionResult> GetMusicGenreImage(
             [FromRoute, Required] string name,
             [FromRoute, Required] ImageType imageType,
             [FromQuery] string tag,
@@ -767,9 +984,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
         {
-            var item = _libraryManager.GetPerson(name);
+            var item = _libraryManager.GetMusicGenre(name);
             if (item == null)
             {
                 return NotFound();
@@ -799,10 +1016,11 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Get studio image by name.
+        /// Get music genre image by name.
         /// </summary>
-        /// <param name="name">Studio name.</param>
+        /// <param name="name">Music genre 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>
@@ -817,23 +1035,23 @@ namespace Jellyfin.Api.Controllers
         /// <param name="blur">Optional. Blur image.</param>
         /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
         /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
         /// <response code="200">Image stream returned.</response>
         /// <response code="404">Item not found.</response>
         /// <returns>
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadStudioImage")]
+        [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
-        public async Task<ActionResult> GetStudioImage(
+        public async Task<ActionResult> GetMusicGenreImageByIndex(
             [FromRoute, Required] string name,
             [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? maxHeight,
             [FromQuery] double? percentPlayed,
@@ -845,10 +1063,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? addPlayedIndicator,
             [FromQuery] int? blur,
             [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)
             {
                 return NotFound();
@@ -878,9 +1095,9 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Get user profile image.
+        /// Get person image by name.
         /// </summary>
-        /// <param name="userId">User id.</param>
+        /// <param name="name">Person name.</param>
         /// <param name="imageType">Image type.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
@@ -903,15 +1120,15 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex?}", Name = "HeadUserImage")]
+        [HttpGet("Persons/{name}/Images/{imageType}")]
+        [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
-        public async Task<ActionResult> GetUserImage(
-            [FromRoute, Required] Guid userId,
+        public async Task<ActionResult> GetPersonImage(
+            [FromRoute, Required] string name,
             [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
+            [FromQuery] string tag,
             [FromQuery] ImageFormat? format,
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxHeight,
@@ -925,10 +1142,423 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [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();
             }

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

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

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

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

+ 318 - 76
Jellyfin.Api/Controllers/ItemsController.cs

@@ -60,7 +60,6 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Gets items based on a query.
         /// </summary>
-        /// <param name="uId">The user id supplied in the /Users/{uid}/Items.</param>
         /// <param name="userId">The user id supplied as query parameter.</param>
         /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
         /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
@@ -143,10 +142,8 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableImages">Optional, include image information in output.</param>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
         [HttpGet("Items")]
-        [HttpGet("Users/{uId}/Items", Name = "GetItems_2")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetItems(
-            [FromRoute] Guid? uId,
             [FromQuery] Guid? userId,
             [FromQuery] string? maxOfficialRating,
             [FromQuery] bool? hasThemeSong,
@@ -159,7 +156,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? hasParentalRating,
             [FromQuery] bool? isHd,
             [FromQuery] bool? is4K,
-            [FromQuery] string? locationTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
             [FromQuery] bool? isMissing,
             [FromQuery] bool? isUnaired,
@@ -173,7 +170,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? hasImdbId,
             [FromQuery] bool? hasTmdbId,
             [FromQuery] bool? hasTvdbId,
-            [FromQuery] string? excludeItemIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] bool? recursive,
@@ -181,34 +178,34 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? sortOrder,
             [FromQuery] string? parentId,
             [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] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
             [FromQuery] string? sortBy,
             [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] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? artists,
-            [FromQuery] string? excludeArtistIds,
-            [FromQuery] string? artistIds,
-            [FromQuery] string? albumArtistIds,
-            [FromQuery] string? contributingArtistIds,
-            [FromQuery] string? albums,
-            [FromQuery] string? albumIds,
-            [FromQuery] string? ids,
-            [FromQuery] string? videoTypes,
+            [FromQuery, 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,
@@ -219,18 +216,15 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxHeight,
             [FromQuery] bool? is3D,
-            [FromQuery] string? seriesStatus,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
             [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? enableImages = true)
         {
-            // use user id route parameter over query parameter
-            userId = uId ?? userId;
-
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
@@ -238,8 +232,9 @@ namespace Jellyfin.Api.Controllers
                 .AddClientFields(Request)
                 .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;
             }
@@ -262,7 +257,7 @@ namespace Jellyfin.Api.Controllers
                 && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
             {
                 recursive = true;
-                includeItemTypes = "Playlist";
+                includeItemTypes = new[] { "Playlist" };
             }
 
             bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id)
@@ -291,14 +286,14 @@ namespace Jellyfin.Api.Controllers
                 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!)
                 {
                     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,
                     OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
                     IsFavorite = isFavorite,
@@ -330,28 +325,28 @@ namespace Jellyfin.Api.Controllers
                     HasTrailer = hasTrailer,
                     IsHD = isHd,
                     Is4K = is4K,
-                    Tags = RequestHelpers.Split(tags, '|', true),
-                    OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                    Genres = RequestHelpers.Split(genres, '|', true),
-                    ArtistIds = RequestHelpers.GetGuids(artistIds),
-                    AlbumArtistIds = RequestHelpers.GetGuids(albumArtistIds),
-                    ContributingArtistIds = RequestHelpers.GetGuids(contributingArtistIds),
-                    GenreIds = RequestHelpers.GetGuids(genreIds),
-                    StudioIds = RequestHelpers.GetGuids(studioIds),
+                    Tags = tags,
+                    OfficialRatings = officialRatings,
+                    Genres = genres,
+                    ArtistIds = artistIds,
+                    AlbumArtistIds = albumArtistIds,
+                    ContributingArtistIds = contributingArtistIds,
+                    GenreIds = genreIds,
+                    StudioIds = studioIds,
                     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,
-                    VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(),
+                    VideoTypes = videoTypes,
                     AdjacentTo = adjacentTo,
-                    ItemIds = RequestHelpers.GetGuids(ids),
+                    ItemIds = ids,
                     MinCommunityRating = minCommunityRating,
                     MinCriticRating = minCriticRating,
                     ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId),
                     ParentIndexNumber = parentIndexNumber,
                     EnableTotalRecordCount = enableTotalRecordCount,
-                    ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds),
+                    ExcludeItemIds = excludeItemIds,
                     DtoOptions = dtoOptions,
                     SearchTerm = searchTerm,
                     MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
@@ -360,7 +355,7 @@ namespace Jellyfin.Api.Controllers
                     MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
                 };
 
-                if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm))
+                if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
                 {
                     query.CollapseBoxSetItems = false;
                 }
@@ -400,9 +395,9 @@ namespace Jellyfin.Api.Controllers
                 }
 
                 // 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
@@ -411,13 +406,9 @@ namespace Jellyfin.Api.Controllers
                     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
@@ -433,9 +424,9 @@ namespace Jellyfin.Api.Controllers
                 }
 
                 // Artists
-                if (!string.IsNullOrEmpty(artists))
+                if (artists.Length != 0)
                 {
-                    query.ArtistIds = artists.Split('|').Select(i =>
+                    query.ArtistIds = artists.Select(i =>
                     {
                         try
                         {
@@ -449,29 +440,29 @@ namespace Jellyfin.Api.Controllers
                 }
 
                 // 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
-                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 });
                     }).ToArray();
                 }
 
                 // Studios
-                if (!string.IsNullOrEmpty(studios))
+                if (studios.Length != 0)
                 {
-                    query.StudioIds = studios.Split('|').Select(i =>
+                    query.StudioIds = studios.Select(i =>
                     {
                         try
                         {
@@ -505,6 +496,257 @@ namespace Jellyfin.Api.Controllers
             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] string? 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>
         /// Gets items based on a query.
         /// </summary>
@@ -533,12 +775,12 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? mediaTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [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? enableImages = true)
         {
@@ -569,13 +811,13 @@ namespace Jellyfin.Api.Controllers
                 ParentId = parentIdGuid,
                 Recursive = true,
                 DtoOptions = dtoOptions,
-                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+                MediaTypes = mediaTypes,
                 IsVirtualItem = false,
                 CollapseBoxSetItems = false,
                 EnableTotalRecordCount = enableTotalRecordCount,
                 AncestorIds = ancestorIds,
-                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
-                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+                IncludeItemTypes = includeItemTypes,
+                ExcludeItemTypes = excludeItemTypes,
                 SearchTerm = searchTerm
             });
 

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

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

+ 13 - 15
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -150,7 +150,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableUserData,
-            [FromQuery] string? sortBy,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
             [FromQuery] SortOrder? sortOrder,
             [FromQuery] bool enableFavoriteSorting = false,
             [FromQuery] bool addCurrentProgram = true)
@@ -175,7 +175,7 @@ namespace Jellyfin.Api.Controllers
                     IsNews = isNews,
                     IsKids = isKids,
                     IsSports = isSports,
-                    SortBy = RequestHelpers.Split(sortBy, ',', true),
+                    SortBy = sortBy,
                     SortOrder = sortOrder ?? SortOrder.Ascending,
                     AddCurrentProgram = addCurrentProgram
                 },
@@ -539,7 +539,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms(
-            [FromQuery] string? channelIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds,
             [FromQuery] Guid? userId,
             [FromQuery] DateTime? minStartDate,
             [FromQuery] bool? hasAired,
@@ -556,8 +556,8 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] string? sortBy,
             [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] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -573,8 +573,7 @@ namespace Jellyfin.Api.Controllers
 
             var query = new InternalItemsQuery(user)
             {
-                ChannelIds = RequestHelpers.Split(channelIds, ',', true)
-                    .Select(i => new Guid(i)).ToArray(),
+                ChannelIds = channelIds,
                 HasAired = hasAired,
                 IsAiring = isAiring,
                 EnableTotalRecordCount = enableTotalRecordCount,
@@ -591,8 +590,8 @@ namespace Jellyfin.Api.Controllers
                 IsKids = isKids,
                 IsSports = isSports,
                 SeriesTimerId = seriesTimerId,
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds)
+                Genres = genres,
+                GenreIds = genreIds
             };
 
             if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty))
@@ -628,8 +627,7 @@ namespace Jellyfin.Api.Controllers
 
             var query = new InternalItemsQuery(user)
             {
-                ChannelIds = RequestHelpers.Split(body.ChannelIds, ',', true)
-                    .Select(i => new Guid(i)).ToArray(),
+                ChannelIds = body.ChannelIds,
                 HasAired = body.HasAired,
                 IsAiring = body.IsAiring,
                 EnableTotalRecordCount = body.EnableTotalRecordCount,
@@ -646,8 +644,8 @@ namespace Jellyfin.Api.Controllers
                 IsKids = body.IsKids,
                 IsSports = body.IsSports,
                 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))
@@ -703,7 +701,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] string? genreIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool enableTotalRecordCount = true)
@@ -723,7 +721,7 @@ namespace Jellyfin.Api.Controllers
                 IsNews = isNews,
                 IsSports = isSports,
                 EnableTotalRecordCount = enableTotalRecordCount,
-                GenreIds = RequestHelpers.GetGuids(genreIds)
+                GenreIds = genreIds
             };
 
             var dtoOptions = new DtoOptions { Fields = fields }

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

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

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

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

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

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

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Constants;
@@ -74,8 +74,8 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [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] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers
 
             var query = new InternalItemsQuery(user)
             {
-                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
-                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                ExcludeItemTypes = excludeItemTypes,
+                IncludeItemTypes = includeItemTypes,
                 StartIndex = startIndex,
                 Limit = limit,
                 IsFavorite = isFavorite,
@@ -123,7 +123,7 @@ namespace Jellyfin.Api.Controllers
 
             var result = _libraryManager.GetMusicGenres(query);
 
-            var shouldIncludeItemTypes = !string.IsNullOrWhiteSpace(includeItemTypes);
+            var shouldIncludeItemTypes = includeItemTypes.Length != 0;
             return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
 

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

@@ -99,7 +99,7 @@ namespace Jellyfin.Api.Controllers
             var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
             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();
             }
 

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

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

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

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

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

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

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

@@ -5,6 +5,7 @@ using System.Globalization;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -82,9 +83,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] Guid? userId,
             [FromQuery, Required] string searchTerm,
-            [FromQuery] string? includeItemTypes,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? mediaTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
             [FromQuery] string? parentId,
             [FromQuery] bool? isMovie,
             [FromQuery] bool? isSeries,
@@ -108,9 +109,9 @@ namespace Jellyfin.Api.Controllers
                 IncludeStudios = includeStudios,
                 StartIndex = startIndex,
                 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,
 
                 IsKids = isKids,

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

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

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

@@ -72,9 +72,9 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         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();
             return NoContent();
         }

+ 6 - 9
Jellyfin.Api/Controllers/StudiosController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
@@ -73,8 +73,8 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [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? enableUserData,
             [FromQuery] int? imageTypeLimit,
@@ -94,13 +94,10 @@ namespace Jellyfin.Api.Controllers
 
             var parentItem = _libraryManager.GetParentItem(parentId, userId);
 
-            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
-            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
-
             var query = new InternalItemsQuery(user)
             {
-                ExcludeItemTypes = excludeItemTypesArr,
-                IncludeItemTypes = includeItemTypesArr,
+                ExcludeItemTypes = excludeItemTypes,
+                IncludeItemTypes = includeItemTypes,
                 StartIndex = startIndex,
                 Limit = limit,
                 IsFavorite = isFavorite,
@@ -125,7 +122,7 @@ namespace Jellyfin.Api.Controllers
             }
 
             var result = _libraryManager.GetStudios(query);
-            var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
+            var shouldIncludeItemTypes = includeItemTypes.Length != 0;
             return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
 

+ 40 - 2
Jellyfin.Api/Controllers/SubtitleController.cs

@@ -193,7 +193,6 @@ namespace Jellyfin.Api.Controllers
         /// <response code="200">File returned.</response>
         /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
         [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")]
-        [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}", Name = "GetSubtitle_2")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesFile("text/*")]
         public async Task<ActionResult> GetSubtitle(
@@ -204,7 +203,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] long? endPositionTicks,
             [FromQuery] bool copyTimestamps = false,
             [FromQuery] bool addVttTimeMap = false,
-            [FromRoute] long startPositionTicks = 0)
+            [FromQuery] long startPositionTicks = 0)
         {
             if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
             {
@@ -249,6 +248,43 @@ namespace Jellyfin.Api.Controllers
                 MimeTypes.GetMimeType("file." + format));
         }
 
+        /// <summary>
+        /// Gets subtitles in a specified format.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="mediaSourceId">The media source id.</param>
+        /// <param name="index">The subtitle stream index.</param>
+        /// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param>
+        /// <param name="format">The format of the returned subtitle.</param>
+        /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
+        /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
+        /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
+        /// <response code="200">File returned.</response>
+        /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
+        [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesFile("text/*")]
+        public Task<ActionResult> GetSubtitleWithTicks(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] string mediaSourceId,
+            [FromRoute, Required] int index,
+            [FromRoute, Required] long startPositionTicks,
+            [FromRoute, Required] string format,
+            [FromQuery] long? endPositionTicks,
+            [FromQuery] bool copyTimestamps = false,
+            [FromQuery] bool addVttTimeMap = false)
+        {
+            return GetSubtitle(
+                itemId,
+                mediaSourceId,
+                index,
+                format,
+                endPositionTicks,
+                copyTimestamps,
+                addVttTimeMap,
+                startPositionTicks);
+        }
+
         /// <summary>
         /// Gets an HLS subtitle playlist.
         /// </summary>
@@ -335,6 +371,7 @@ namespace Jellyfin.Api.Controllers
         /// <response code="204">Subtitle uploaded.</response>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
         [HttpPost("Videos/{itemId}/Subtitles")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public async Task<ActionResult> UploadSubtitle(
             [FromRoute, Required] Guid itemId,
             [FromBody, Required] UploadSubtitleDto body)
@@ -446,6 +483,7 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("FallbackFont/Fonts/{name}")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesFile("font/*")]
         public ActionResult GetFallbackFont([FromRoute, Required] string name)
         {
             var encodingOptions = _serverConfigurationManager.GetEncodingOptions();

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

@@ -1,9 +1,10 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -58,8 +59,8 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
             [FromRoute, Required] Guid userId,
-            [FromQuery] string? mediaType,
-            [FromQuery] string? type,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] type,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] bool enableTotalRecordCount = false)
@@ -70,8 +71,8 @@ namespace Jellyfin.Api.Controllers
             var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
             {
                 OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
-                MediaTypes = RequestHelpers.Split(mediaType!, ',', true),
-                IncludeItemTypes = RequestHelpers.Split(type!, ',', true),
+                MediaTypes = mediaType,
+                IncludeItemTypes = type,
                 IsVirtualItem = false,
                 StartIndex = startIndex,
                 Limit = limit,

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

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

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

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

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Globalization;
 using MediaBrowser.Model.SyncPlay;
 using Microsoft.AspNetCore.Http;

+ 24 - 25
Jellyfin.Api/Controllers/TrailersController.cs

@@ -125,7 +125,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? hasParentalRating,
             [FromQuery] bool? isHd,
             [FromQuery] bool? is4K,
-            [FromQuery] string? locationTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
             [FromQuery] bool? isMissing,
             [FromQuery] bool? isUnaired,
@@ -139,7 +139,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? hasImdbId,
             [FromQuery] bool? hasTmdbId,
             [FromQuery] bool? hasTvdbId,
-            [FromQuery] string? excludeItemIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] bool? recursive,
@@ -147,33 +147,33 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? sortOrder,
             [FromQuery] string? parentId,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
             [FromQuery] string? sortBy,
             [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] int? imageTypeLimit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? artists,
-            [FromQuery] string? excludeArtistIds,
-            [FromQuery] string? artistIds,
-            [FromQuery] string? albumArtistIds,
-            [FromQuery] string? contributingArtistIds,
-            [FromQuery] string? albums,
-            [FromQuery] string? albumIds,
-            [FromQuery] string? ids,
-            [FromQuery] string? videoTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] 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(CommaDelimitedArrayModelBinder))] 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,
@@ -184,20 +184,19 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxHeight,
             [FromQuery] bool? is3D,
-            [FromQuery] string? seriesStatus,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
             [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? enableImages = true)
         {
-            var includeItemTypes = "Trailer";
+            var includeItemTypes = new[] { "Trailer" };
 
             return _itemsController
                 .GetItems(
-                    userId,
                     userId,
                     maxOfficialRating,
                     hasThemeSong,

+ 10 - 7
Jellyfin.Api/Controllers/UniversalAudioController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Globalization;
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.StreamingDtos;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Devices;
@@ -96,7 +97,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesAudioFile]
         public async Task<ActionResult> GetUniversalAudioStream(
             [FromRoute, Required] Guid itemId,
-            [FromQuery] string? container,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container,
             [FromQuery] string? mediaSourceId,
             [FromQuery] string? deviceId,
             [FromQuery] Guid? userId,
@@ -191,8 +192,11 @@ namespace Jellyfin.Api.Controllers
             if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
             {
                 // hls segment container can only be mpegts or fmp4 per ffmpeg documentation
+                // ffmpeg option -> file extension
+                //        mpegts -> ts
+                //          fmp4 -> mp4
                 // TODO: remove this when we switch back to the segment muxer
-                var supportedHlsContainers = new[] { "mpegts", "fmp4" };
+                var supportedHlsContainers = new[] { "ts", "mp4" };
 
                 var dynamicHlsRequestDto = new HlsAudioRequestDto
                 {
@@ -201,7 +205,7 @@ namespace Jellyfin.Api.Controllers
                     Static = isStatic,
                     PlaySessionId = info.PlaySessionId,
                     // fallback to mpegts if device reports some weird value unsupported by hls
-                    SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "mpegts",
+                    SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts",
                     MediaSourceId = mediaSourceId,
                     DeviceId = deviceId,
                     AudioCodec = audioCodec,
@@ -258,7 +262,7 @@ namespace Jellyfin.Api.Controllers
         }
 
         private DeviceProfile GetDeviceProfile(
-            string? container,
+            string[] containers,
             string? transcodingContainer,
             string? audioCodec,
             string? transcodingProtocol,
@@ -270,7 +274,6 @@ namespace Jellyfin.Api.Controllers
         {
             var deviceProfile = new DeviceProfile();
 
-            var containers = RequestHelpers.Split(container, ',', true);
             int len = containers.Length;
             var directPlayProfiles = new DirectPlayProfile[len];
             for (int i = 0; i < len; i++)
@@ -327,7 +330,7 @@ namespace Jellyfin.Api.Controllers
             if (conditions.Count > 0)
             {
                 // codec profile
-                codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = container, Conditions = conditions.ToArray() });
+                codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = string.Join(',', containers), Conditions = conditions.ToArray() });
             }
 
             deviceProfile.CodecProfiles = codecProfiles.ToArray();

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

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

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

@@ -269,7 +269,7 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid userId,
             [FromQuery] Guid? parentId,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
             [FromQuery] bool? isPlayed,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
@@ -296,7 +296,7 @@ namespace Jellyfin.Api.Controllers
                 new LatestItemsQuery
                 {
                     GroupItems = groupItems,
-                    IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                    IncludeItemTypes = includeItemTypes,
                     IsPlayed = isPlayed,
                     Limit = limit,
                     ParentId = parentId ?? Guid.Empty,

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

@@ -1,10 +1,11 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Globalization;
 using System.Linq;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.UserViewDtos;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -67,7 +68,7 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<QueryResult<BaseItemDto>> GetUserViews(
             [FromRoute, Required] Guid userId,
             [FromQuery] bool? includeExternalContent,
-            [FromQuery] string? presetViews,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews,
             [FromQuery] bool includeHidden = false)
         {
             var query = new UserViewQuery
@@ -81,9 +82,9 @@ namespace Jellyfin.Api.Controllers
                 query.IncludeExternalContent = includeExternalContent.Value;
             }
 
-            if (!string.IsNullOrWhiteSpace(presetViews))
+            if (presetViews.Length != 0)
             {
-                query.PresetViews = RequestHelpers.Split(presetViews, ',', true);
+                query.PresetViews = presetViews;
             }
 
             var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty;

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

@@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations;
 using System.Net.Mime;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Api.Attributes;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
@@ -43,7 +44,7 @@ namespace Jellyfin.Api.Controllers
         /// <response code="404">Video or attachment not found.</response>
         /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns>
         [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")]
-        [Produces(MediaTypeNames.Application.Octet)]
+        [ProducesFile(MediaTypeNames.Application.Octet)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult> GetAttachment(

+ 131 - 40
Jellyfin.Api/Controllers/VideoHlsController.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Globalization;
 using System.IO;
+using System.Runtime.InteropServices;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
@@ -296,23 +297,23 @@ namespace Jellyfin.Api.Controllers
                 .ConfigureAwait(false);
 
             TranscodingJobDto? job = null;
-            var playlist = state.OutputFilePath;
+            var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
 
-            if (!System.IO.File.Exists(playlist))
+            if (!System.IO.File.Exists(playlistPath))
             {
-                var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlist);
+                var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
                 await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
                 try
                 {
-                    if (!System.IO.File.Exists(playlist))
+                    if (!System.IO.File.Exists(playlistPath))
                     {
                         // If the playlist doesn't already exist, startup ffmpeg
                         try
                         {
                             job = await _transcodingJobHelper.StartFfMpeg(
                                     state,
-                                    playlist,
-                                    GetCommandLineArguments(playlist, state),
+                                    playlistPath,
+                                    GetCommandLineArguments(playlistPath, state),
                                     Request,
                                     TranscodingJobType,
                                     cancellationTokenSource)
@@ -328,7 +329,7 @@ namespace Jellyfin.Api.Controllers
                         minSegments = state.MinSegments;
                         if (minSegments > 0)
                         {
-                            await HlsHelpers.WaitForMinimumSegmentCount(playlist, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false);
+                            await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false);
                         }
                     }
                 }
@@ -338,14 +339,14 @@ namespace Jellyfin.Api.Controllers
                 }
             }
 
-            job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlist, TranscodingJobType);
+            job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
 
             if (job != null)
             {
                 _transcodingJobHelper.OnTranscodeEndRequest(job);
             }
 
-            var playlistText = HlsHelpers.GetLivePlaylistText(playlist, state.SegmentLength);
+            var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state);
 
             return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8"));
         }
@@ -361,15 +362,44 @@ namespace Jellyfin.Api.Controllers
             var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
             var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); // GetNumberOfThreads is static.
             var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
-            var format = !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) ? "." + state.Request.SegmentContainer : ".ts";
+            var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
+
             var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
-            var outputTsArg = Path.Combine(directory, Path.GetFileNameWithoutExtension(outputPath)) + "%d" + format;
+            var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+            var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
+            var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
+            var outputTsArg = outputPrefix + "%d" + outputExtension;
 
-            var segmentFormat = format.TrimStart('.');
+            var segmentFormat = outputExtension.TrimStart('.');
             if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
             {
                 segmentFormat = "mpegts";
             }
+            else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
+            {
+                var outputFmp4HeaderArg = string.Empty;
+                var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+                if (isWindows)
+                {
+                    // on Windows, the path of fmp4 header file needs to be configured
+                    outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"";
+                }
+                else
+                {
+                    // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
+                    outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"";
+                }
+
+                segmentFormat = "fmp4" + outputFmp4HeaderArg;
+            }
+            else
+            {
+                _logger.LogError("Invalid HLS segment container: {SegmentFormat}", segmentFormat);
+            }
+
+            var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
+                ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
+                : "128";
 
             var baseUrlParam = string.Format(
                 CultureInfo.InvariantCulture,
@@ -378,20 +408,19 @@ namespace Jellyfin.Api.Controllers
 
             return string.Format(
                     CultureInfo.InvariantCulture,
-                    "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {7} -individual_header_trailer 0 -segment_format {8} -segment_list_entry_prefix {9} -segment_list_type m3u8 -segment_start_number 0 -segment_list \"{10}\" -y \"{11}\"",
+                    "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number 0 -hls_base_url {9} -hls_playlist_type event -hls_segment_filename \"{10}\" -y \"{11}\"",
                     inputModifier,
                     _encodingHelper.GetInputArgument(state, _encodingOptions),
                     threads,
-                    _encodingHelper.GetMapArgs(state),
+                    mapArgs,
                     GetVideoArguments(state),
                     GetAudioArguments(state),
+                    maxMuxingQueueSize,
                     state.SegmentLength.ToString(CultureInfo.InvariantCulture),
-                    string.Empty,
                     segmentFormat,
                     baseUrlParam,
-                    outputPath,
-                    outputTsArg)
-                .Trim();
+                    outputTsArg,
+                    outputPath).Trim();
         }
 
         /// <summary>
@@ -401,14 +430,53 @@ namespace Jellyfin.Api.Controllers
         /// <returns>The command line arguments for audio transcoding.</returns>
         private string GetAudioArguments(StreamState state)
         {
-            var codec = _encodingHelper.GetAudioEncoder(state);
+            if (state.AudioStream == null)
+            {
+                return string.Empty;
+            }
 
-            if (EncodingHelper.IsCopyCodec(codec))
+            var audioCodec = _encodingHelper.GetAudioEncoder(state);
+
+            if (!state.IsOutputVideo)
+            {
+                if (EncodingHelper.IsCopyCodec(audioCodec))
+                {
+                    var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
+
+                    return "-acodec copy -strict -2" + bitStreamArgs;
+                }
+
+                var audioTranscodeParams = string.Empty;
+
+                audioTranscodeParams += "-acodec " + audioCodec;
+
+                if (state.OutputAudioBitrate.HasValue)
+                {
+                    audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture);
+                }
+
+                if (state.OutputAudioChannels.HasValue)
+                {
+                    audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture);
+                }
+
+                if (state.OutputAudioSampleRate.HasValue)
+                {
+                    audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
+                }
+
+                audioTranscodeParams += " -vn";
+                return audioTranscodeParams;
+            }
+
+            if (EncodingHelper.IsCopyCodec(audioCodec))
             {
-                return "-codec:a:0 copy";
+                var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
+
+                return "-acodec copy -strict -2" + bitStreamArgs;
             }
 
-            var args = "-codec:a:0 " + codec;
+            var args = "-codec:a:0 " + audioCodec;
 
             var channels = state.OutputAudioChannels;
 
@@ -429,7 +497,7 @@ namespace Jellyfin.Api.Controllers
                 args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
             }
 
-            args += " " + _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
+            args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
 
             return args;
         }
@@ -441,6 +509,11 @@ namespace Jellyfin.Api.Controllers
         /// <returns>The command line arguments for video transcoding.</returns>
         private string GetVideoArguments(StreamState state)
         {
+            if (state.VideoStream == null)
+            {
+                return string.Empty;
+            }
+
             if (!state.IsOutputVideo)
             {
                 return string.Empty;
@@ -450,47 +523,65 @@ namespace Jellyfin.Api.Controllers
 
             var args = "-codec:v:0 " + codec;
 
+            // Prefer hvc1 to hev1.
+            if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+            {
+                args += " -tag:v:0 hvc1";
+            }
+
             // if (state.EnableMpegtsM2TsMode)
             // {
             //     args += " -mpegts_m2ts_mode 1";
             // }
 
-            // See if we can save come cpu cycles by avoiding encoding
-            if (codec.Equals("copy", StringComparison.OrdinalIgnoreCase))
+            // See if we can save come cpu cycles by avoiding encoding.
+            if (EncodingHelper.IsCopyCodec(codec))
             {
-                // if h264_mp4toannexb is ever added, do not use it for live tv
-                if (state.VideoStream != null &&
-                    !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
+                // If h264_mp4toannexb is ever added, do not use it for live tv.
+                if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
                 {
-                    string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream);
+                    string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream);
                     if (!string.IsNullOrEmpty(bitStreamArgs))
                     {
                         args += " " + bitStreamArgs;
                     }
                 }
+
+                args += " -start_at_zero";
             }
             else
             {
-                var keyFrameArg = string.Format(
-                    CultureInfo.InvariantCulture,
-                    " -force_key_frames \"expr:gte(t,n_forced*{0})\"",
-                    state.SegmentLength.ToString(CultureInfo.InvariantCulture));
+                args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset);
 
-                var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
+                // Set the key frame params for video encoding to match the hls segment time.
+                args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, true, null);
 
-                args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset) + keyFrameArg;
-
-                // Add resolution params, if specified
-                if (!hasGraphicalSubs)
+                // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
+                if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
                 {
-                    args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
+                    args += " -bf 0";
                 }
 
-                // This is for internal graphical subs
+                var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
+
                 if (hasGraphicalSubs)
                 {
+                    // Graphical subs overlay and resolution params.
                     args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
                 }
+                else
+                {
+                    // Resolution params.
+                    args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
+                }
+
+                if (state.SubtitleStream == null || !state.SubtitleStream.IsExternal || state.SubtitleStream.IsTextSubtitleStream)
+                {
+                    args += " -start_at_zero";
+                }
             }
 
             args += " -flags -global_header";

+ 165 - 5
Jellyfin.Api/Controllers/VideosController.cs

@@ -10,6 +10,7 @@ using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.StreamingDtos;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Net;
@@ -203,9 +204,9 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status400BadRequest)]
-        public async Task<ActionResult> MergeVersions([FromQuery, Required] string itemIds)
+        public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds)
         {
-            var items = RequestHelpers.Split(itemIds, ',', true)
+            var items = itemIds
                 .Select(i => _libraryManager.GetItemById(i))
                 .OfType<Video>()
                 .OrderBy(i => i.Id)
@@ -326,15 +327,13 @@ namespace Jellyfin.Api.Controllers
         /// <param name="streamOptions">Optional. The streaming options.</param>
         /// <response code="200">Video stream returned.</response>
         /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("{itemId}/{stream=stream}.{container?}", Name = "GetVideoStreamWithExt")]
         [HttpGet("{itemId}/stream")]
-        [HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadVideoStreamWithExt")]
         [HttpHead("{itemId}/stream", Name = "HeadVideoStream")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesVideoFile]
         public async Task<ActionResult> GetVideoStream(
             [FromRoute, Required] Guid itemId,
-            [FromRoute] string? container,
+            [FromQuery] string? container,
             [FromQuery] bool? @static,
             [FromQuery] string? @params,
             [FromQuery] string? tag,
@@ -529,5 +528,166 @@ namespace Jellyfin.Api.Controllers
                 _transcodingJobType,
                 cancellationTokenSource).ConfigureAwait(false);
         }
+
+        /// <summary>
+        /// Gets a video stream.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
+        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+        /// <param name="params">The streaming parameters.</param>
+        /// <param name="tag">The tag.</param>
+        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <param name="segmentContainer">The segment container.</param>
+        /// <param name="segmentLength">The segment lenght.</param>
+        /// <param name="minSegments">The minimum number of segments.</param>
+        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+        /// <param name="maxRefFrames">Optional.</param>
+        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+        /// <param name="requireAvc">Optional. Whether to require avc.</param>
+        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+        /// <param name="liveStreamId">The live stream id.</param>
+        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+        /// <param name="streamOptions">Optional. The streaming options.</param>
+        /// <response code="200">Video stream returned.</response>
+        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+        [HttpGet("{itemId}/{stream=stream}.{container}")]
+        [HttpHead("{itemId}/{stream=stream}.{container}", Name = "HeadVideoStreamByContainer")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesVideoFile]
+        public Task<ActionResult> GetVideoStreamByContainer(
+            [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? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext context,
+            [FromQuery] Dictionary<string, string> streamOptions)
+        {
+            return GetVideoStream(
+                itemId,
+                container,
+                @static,
+                @params,
+                tag,
+                deviceProfileId,
+                playSessionId,
+                segmentContainer,
+                segmentLength,
+                minSegments,
+                mediaSourceId,
+                deviceId,
+                audioCodec,
+                enableAutoStreamCopy,
+                allowVideoStreamCopy,
+                allowAudioStreamCopy,
+                breakOnNonKeyFrames,
+                audioSampleRate,
+                maxAudioBitDepth,
+                audioBitRate,
+                audioChannels,
+                maxAudioChannels,
+                profile,
+                level,
+                framerate,
+                maxFramerate,
+                copyTimestamps,
+                startTimeTicks,
+                width,
+                height,
+                videoBitRate,
+                subtitleStreamIndex,
+                subtitleMethod,
+                maxRefFrames,
+                maxVideoBitDepth,
+                requireAvc,
+                deInterlace,
+                requireNonAnamorphic,
+                transcodingMaxAudioChannels,
+                cpuCoreLimit,
+                liveStreamId,
+                enableMpegtsM2TsMode,
+                videoCodec,
+                subtitleCodec,
+                transcodingReasons,
+                audioStreamIndex,
+                videoStreamIndex,
+                context,
+                streamOptions);
+        }
     }
 }

+ 8 - 12
Jellyfin.Api/Controllers/YearsController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
@@ -73,9 +73,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? sortOrder,
             [FromQuery] string? parentId,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? includeItemTypes,
-            [FromQuery] string? mediaTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
             [FromQuery] string? sortBy,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
@@ -103,19 +103,15 @@ namespace Jellyfin.Api.Controllers
 
             IList<BaseItem> items;
 
-            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
-            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
-            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
-
             var query = new InternalItemsQuery(user)
             {
-                ExcludeItemTypes = excludeItemTypesArr,
-                IncludeItemTypes = includeItemTypesArr,
-                MediaTypes = mediaTypesArr,
+                ExcludeItemTypes = excludeItemTypes,
+                IncludeItemTypes = includeItemTypes,
+                MediaTypes = mediaTypes,
                 DtoOptions = dtoOptions
             };
 
-            bool Filter(BaseItem i) => FilterItem(i, excludeItemTypesArr, includeItemTypesArr, mediaTypesArr);
+            bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes);
 
             if (parentItem.IsFolder)
             {

+ 180 - 18
Jellyfin.Api/Helpers/DynamicHlsHelper.cs

@@ -207,7 +207,61 @@ namespace Jellyfin.Api.Helpers
                 AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
             }
 
-            AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
+            var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
+
+            if (state.VideoStream != null && state.VideoRequest != null)
+            {
+                // Provide SDR HEVC entrance for backward compatibility.
+                if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+                    && !string.IsNullOrEmpty(state.VideoStream.VideoRange)
+                    && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase)
+                    && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+                {
+                    var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
+                    if (requestedVideoProfiles != null && requestedVideoProfiles.Length > 0)
+                    {
+                        // Force HEVC Main Profile and disable video stream copy.
+                        state.OutputVideoCodec = "hevc";
+                        var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(",", requestedVideoProfiles), "main");
+                        sdrVideoUrl += "&AllowVideoStreamCopy=false";
+
+                        EncodingHelper encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
+                        var sdrOutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec) ?? 0;
+                        var sdrOutputAudioBitrate = encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0;
+                        var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate;
+
+                        AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup);
+
+                        // Restore the video codec
+                        state.OutputVideoCodec = "copy";
+                    }
+                }
+
+                // Provide Level 5.0 entrance for backward compatibility.
+                // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
+                // but in fact it is capable of playing videos up to Level 6.1.
+                if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+                    && state.VideoStream.Level.HasValue
+                    && state.VideoStream.Level > 150
+                    && !string.IsNullOrEmpty(state.VideoStream.VideoRange)
+                    && string.Equals(state.VideoStream.VideoRange, "SDR", StringComparison.OrdinalIgnoreCase)
+                    && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+                {
+                    var playlistCodecsField = new StringBuilder();
+                    AppendPlaylistCodecsField(playlistCodecsField, state);
+
+                    // Force the video level to 5.0.
+                    var originalLevel = state.VideoStream.Level;
+                    state.VideoStream.Level = 150;
+                    var newPlaylistCodecsField = new StringBuilder();
+                    AppendPlaylistCodecsField(newPlaylistCodecsField, state);
+
+                    // Restore the video level.
+                    state.VideoStream.Level = originalLevel;
+                    var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField);
+                    builder.Append(newPlaylist);
+                }
+            }
 
             if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp()))
             {
@@ -217,40 +271,77 @@ namespace Jellyfin.Api.Helpers
                 var variation = GetBitrateVariation(totalBitrate);
 
                 var newBitrate = totalBitrate - variation;
-                var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+                var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
                 AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
 
                 variation *= 2;
                 newBitrate = totalBitrate - variation;
-                variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+                variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
                 AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
             }
 
             return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
         }
 
-        private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
+        private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
         {
-            builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
+            var playlistBuilder = new StringBuilder();
+            playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
                 .Append(bitrate.ToString(CultureInfo.InvariantCulture))
                 .Append(",AVERAGE-BANDWIDTH=")
                 .Append(bitrate.ToString(CultureInfo.InvariantCulture));
 
-            AppendPlaylistCodecsField(builder, state);
+            AppendPlaylistVideoRangeField(playlistBuilder, state);
+
+            AppendPlaylistCodecsField(playlistBuilder, state);
 
-            AppendPlaylistResolutionField(builder, state);
+            AppendPlaylistResolutionField(playlistBuilder, state);
 
-            AppendPlaylistFramerateField(builder, state);
+            AppendPlaylistFramerateField(playlistBuilder, state);
 
             if (!string.IsNullOrWhiteSpace(subtitleGroup))
             {
-                builder.Append(",SUBTITLES=\"")
+                playlistBuilder.Append(",SUBTITLES=\"")
                     .Append(subtitleGroup)
                     .Append('"');
             }
 
-            builder.Append(Environment.NewLine);
-            builder.AppendLine(url);
+            playlistBuilder.Append(Environment.NewLine);
+            playlistBuilder.AppendLine(url);
+            builder.Append(playlistBuilder);
+
+            return playlistBuilder;
+        }
+
+        /// <summary>
+        /// Appends a VIDEO-RANGE field containing the range of the output video stream.
+        /// </summary>
+        /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+        /// <param name="builder">StringBuilder to append the field to.</param>
+        /// <param name="state">StreamState of the current stream.</param>
+        private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state)
+        {
+            if (state.VideoStream != null && !string.IsNullOrEmpty(state.VideoStream.VideoRange))
+            {
+                var videoRange = state.VideoStream.VideoRange;
+                if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+                {
+                    if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase))
+                    {
+                        builder.Append(",VIDEO-RANGE=SDR");
+                    }
+
+                    if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase))
+                    {
+                        builder.Append(",VIDEO-RANGE=PQ");
+                    }
+                }
+                else
+                {
+                    // Currently we only encode to SDR.
+                    builder.Append(",VIDEO-RANGE=SDR");
+                }
+            }
         }
 
         /// <summary>
@@ -419,15 +510,27 @@ namespace Jellyfin.Api.Helpers
         /// <returns>H.26X level of the output video stream.</returns>
         private int? GetOutputVideoCodecLevel(StreamState state)
         {
-            string? levelString;
+            string levelString = string.Empty;
             if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+                && state.VideoStream != null
                 && state.VideoStream.Level.HasValue)
             {
-                levelString = state.VideoStream?.Level.ToString();
+                levelString = state.VideoStream.Level.ToString() ?? string.Empty;
             }
             else
             {
-                levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec);
+                if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
+                {
+                    levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41";
+                    levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
+                }
+
+                if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+                {
+                    levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120";
+                    levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
+                }
             }
 
             if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
@@ -438,6 +541,38 @@ namespace Jellyfin.Api.Helpers
             return null;
         }
 
+        /// <summary>
+        /// Get the H.26X profile of the output video stream.
+        /// </summary>
+        /// <param name="state">StreamState of the current stream.</param>
+        /// <param name="codec">Video codec.</param>
+        /// <returns>H.26X profile of the output video stream.</returns>
+        private string GetOutputVideoCodecProfile(StreamState state, string codec)
+        {
+            string profileString = string.Empty;
+            if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+                && !string.IsNullOrEmpty(state.VideoStream.Profile))
+            {
+                profileString = state.VideoStream.Profile;
+            }
+            else if (!string.IsNullOrEmpty(codec))
+            {
+                profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty;
+                if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
+                {
+                    profileString = profileString ?? "high";
+                }
+
+                if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+                {
+                    profileString = profileString ?? "main";
+                }
+            }
+
+            return profileString;
+        }
+
         /// <summary>
         /// Gets a formatted string of the output audio codec, for use in the CODECS field.
         /// </summary>
@@ -468,6 +603,16 @@ namespace Jellyfin.Api.Helpers
                 return HlsCodecStringHelpers.GetEAC3String();
             }
 
+            if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase))
+            {
+                return HlsCodecStringHelpers.GetFLACString();
+            }
+
+            if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase))
+            {
+                return HlsCodecStringHelpers.GetALACString();
+            }
+
             return string.Empty;
         }
 
@@ -492,15 +637,14 @@ namespace Jellyfin.Api.Helpers
 
             if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase))
             {
-                string? profile = state.GetRequestedProfiles("h264").FirstOrDefault();
+                string profile = GetOutputVideoCodecProfile(state, "h264");
                 return HlsCodecStringHelpers.GetH264String(profile, level);
             }
 
             if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
                 || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
             {
-                string? profile = state.GetRequestedProfiles("h265").FirstOrDefault();
-
+                string profile = GetOutputVideoCodecProfile(state, "hevc");
                 return HlsCodecStringHelpers.GetH265String(profile, level);
             }
 
@@ -544,12 +688,30 @@ namespace Jellyfin.Api.Helpers
             return variation;
         }
 
-        private string ReplaceBitrate(string url, int oldValue, int newValue)
+        private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
         {
             return url.Replace(
                 "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
                 "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
                 StringComparison.OrdinalIgnoreCase);
         }
+
+        private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
+        {
+            string profileStr = codec + "-profile=";
+            return url.Replace(
+                profileStr + oldValue,
+                profileStr + newValue,
+                StringComparison.OrdinalIgnoreCase);
+        }
+
+        private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
+        {
+            var oldPlaylist = playlist.ToString();
+            return oldPlaylist.Replace(
+                oldValue.ToString(),
+                newValue.ToString(),
+                StringComparison.OrdinalIgnoreCase);
+        }
     }
 }

+ 68 - 24
Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs

@@ -9,13 +9,38 @@ namespace Jellyfin.Api.Helpers
     /// </summary>
     public static class HlsCodecStringHelpers
     {
+        /// <summary>
+        /// Codec name for MP3.
+        /// </summary>
+        public const string MP3 = "mp4a.40.34";
+
+        /// <summary>
+        /// Codec name for AC-3.
+        /// </summary>
+        public const string AC3 = "mp4a.a5";
+
+        /// <summary>
+        /// Codec name for E-AC-3.
+        /// </summary>
+        public const string EAC3 = "mp4a.a6";
+
+        /// <summary>
+        /// Codec name for FLAC.
+        /// </summary>
+        public const string FLAC = "fLaC";
+
+        /// <summary>
+        /// Codec name for ALAC.
+        /// </summary>
+        public const string ALAC = "alac";
+
         /// <summary>
         /// Gets a MP3 codec string.
         /// </summary>
         /// <returns>MP3 codec string.</returns>
         public static string GetMP3String()
         {
-            return "mp4a.40.34";
+            return MP3;
         }
 
         /// <summary>
@@ -40,6 +65,42 @@ namespace Jellyfin.Api.Helpers
             return result.ToString();
         }
 
+        /// <summary>
+        /// Gets an AC-3 codec string.
+        /// </summary>
+        /// <returns>AC-3 codec string.</returns>
+        public static string GetAC3String()
+        {
+            return AC3;
+        }
+
+        /// <summary>
+        /// Gets an E-AC-3 codec string.
+        /// </summary>
+        /// <returns>E-AC-3 codec string.</returns>
+        public static string GetEAC3String()
+        {
+            return EAC3;
+        }
+
+        /// <summary>
+        /// Gets an FLAC codec string.
+        /// </summary>
+        /// <returns>FLAC codec string.</returns>
+        public static string GetFLACString()
+        {
+            return FLAC;
+        }
+
+        /// <summary>
+        /// Gets an ALAC codec string.
+        /// </summary>
+        /// <returns>ALAC codec string.</returns>
+        public static string GetALACString()
+        {
+            return ALAC;
+        }
+
         /// <summary>
         /// Gets a H.264 codec string.
         /// </summary>
@@ -85,41 +146,24 @@ namespace Jellyfin.Api.Helpers
             // The h265 syntax is a bit of a mystery at the time this comment was written.
             // This is what I've found through various sources:
             // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
-            StringBuilder result = new StringBuilder("hev1", 16);
+            StringBuilder result = new StringBuilder("hvc1", 16);
 
-            if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase))
+            if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase))
             {
-                result.Append(".2.6");
+                result.Append(".2.4");
             }
             else
             {
                 // Default to main if profile is invalid
-                result.Append(".1.6");
+                result.Append(".1.4");
             }
 
             result.Append(".L")
-                .Append(level * 3)
+                .Append(level)
                 .Append(".B0");
 
             return result.ToString();
         }
-
-        /// <summary>
-        /// Gets an AC-3 codec string.
-        /// </summary>
-        /// <returns>AC-3 codec string.</returns>
-        public static string GetAC3String()
-        {
-            return "mp4a.a5";
-        }
-
-        /// <summary>
-        /// Gets an E-AC-3 codec string.
-        /// </summary>
-        /// <returns>E-AC-3 codec string.</returns>
-        public static string GetEAC3String()
-        {
-            return "mp4a.a6";
-        }
     }
 }

+ 50 - 7
Jellyfin.Api/Helpers/HlsHelpers.cs

@@ -1,8 +1,11 @@
 using System;
 using System.Globalization;
 using System.IO;
+using System.Runtime.InteropServices;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Model.IO;
 using Microsoft.Extensions.Logging;
 
@@ -74,25 +77,65 @@ namespace Jellyfin.Api.Helpers
             }
         }
 
+        /// <summary>
+        /// Gets the #EXT-X-MAP string.
+        /// </summary>
+        /// <param name="outputPath">The output path of the file.</param>
+        /// <param name="state">The <see cref="StreamState"/>.</param>
+        /// <param name="isOsDepends">Get a normal string or depends on OS.</param>
+        /// <returns>The string text of #EXT-X-MAP.</returns>
+        public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends)
+        {
+            var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
+            var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+            var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
+            var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
+
+            // on Linux/Unix
+            // #EXT-X-MAP:URI="prefix-1.mp4"
+            var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension;
+            if (!isOsDepends)
+            {
+                return fmp4InitFileName;
+            }
+
+            var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+            if (isWindows)
+            {
+                // on Windows
+                // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4"
+                fmp4InitFileName = outputPrefix + "-1" + outputExtension;
+            }
+
+            return fmp4InitFileName;
+        }
+
         /// <summary>
         /// Gets the hls playlist text.
         /// </summary>
         /// <param name="path">The path to the playlist file.</param>
-        /// <param name="segmentLength">The segment length.</param>
+        /// <param name="state">The <see cref="StreamState"/>.</param>
         /// <returns>The playlist text as a string.</returns>
-        public static string GetLivePlaylistText(string path, int segmentLength)
+        public static string GetLivePlaylistText(string path, StreamState state)
         {
             using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
             using var reader = new StreamReader(stream);
 
             var text = reader.ReadToEnd();
 
-            text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture);
-
-            var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture);
+            var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
+            if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
+            {
+                var fmp4InitFileName = GetFmp4InitFileName(path, state, true);
+                var baseUrlParam = string.Format(
+                    CultureInfo.InvariantCulture,
+                    "hls/{0}/",
+                    Path.GetFileNameWithoutExtension(path));
+                var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false);
 
-            text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
-            // text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
+                // Replace fMP4 init file URI.
+                text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture);
+            }
 
             return text;
         }

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

@@ -122,49 +122,6 @@ namespace Jellyfin.Api.Helpers
             return session;
         }
 
-        /// <summary>
-        /// Get Guid array from string.
-        /// </summary>
-        /// <param name="value">String value.</param>
-        /// <returns>Guid array.</returns>
-        internal static Guid[] GetGuids(string? value)
-        {
-            if (value == null)
-            {
-                return Array.Empty<Guid>();
-            }
-
-            return Split(value, ',', true)
-                .Select(i => new Guid(i))
-                .ToArray();
-        }
-
-        /// <summary>
-        /// Gets the item fields.
-        /// </summary>
-        /// <param name="fields">The fields string.</param>
-        /// <returns>IEnumerable{ItemFields}.</returns>
-        internal static ItemFields[] GetItemFields(string? fields)
-        {
-            if (string.IsNullOrEmpty(fields))
-            {
-                return Array.Empty<ItemFields>();
-            }
-
-            return Split(fields, ',', true)
-                .Select(v =>
-                {
-                    if (Enum.TryParse(v, true, out ItemFields value))
-                    {
-                        return (ItemFields?)value;
-                    }
-
-                    return null;
-                }).Where(i => i.HasValue)
-                .Select(i => i!.Value)
-                .ToArray();
-        }
-
         internal static QueryResult<BaseItemDto> CreateQueryResult(
             QueryResult<(BaseItem, ItemCounts)> result,
             DtoOptions dtoOptions,

+ 38 - 15
Jellyfin.Api/Helpers/StreamingHelpers.cs

@@ -169,7 +169,9 @@ namespace Jellyfin.Api.Helpers
                 state.DirectStreamProvider = liveStreamInfo.Item2;
             }
 
-            encodingHelper.AttachMediaSourceInfo(state, mediaSource, url);
+            var encodingOptions = serverConfigurationManager.GetEncodingOptions();
+
+            encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url);
 
             string? containerInternal = Path.GetExtension(state.RequestedUrl);
 
@@ -187,7 +189,7 @@ namespace Jellyfin.Api.Helpers
 
             state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
 
-            state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, state.AudioStream);
+            state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream);
 
             state.OutputAudioCodec = streamingRequest.AudioCodec;
 
@@ -200,20 +202,41 @@ namespace Jellyfin.Api.Helpers
 
                 encodingHelper.TryStreamCopy(state);
 
-                if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+                if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue)
                 {
-                    var resolution = ResolutionNormalizer.Normalize(
-                        state.VideoStream?.BitRate,
-                        state.VideoStream?.Width,
-                        state.VideoStream?.Height,
-                        state.OutputVideoBitrate.Value,
-                        state.VideoStream?.Codec,
-                        state.OutputVideoCodec,
-                        state.VideoRequest.MaxWidth,
-                        state.VideoRequest.MaxHeight);
-
-                    state.VideoRequest.MaxWidth = resolution.MaxWidth;
-                    state.VideoRequest.MaxHeight = resolution.MaxHeight;
+                    var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue
+                        && !state.VideoRequest.Height.HasValue
+                        && !state.VideoRequest.MaxWidth.HasValue
+                        && !state.VideoRequest.MaxHeight.HasValue;
+
+                    if (isVideoResolutionNotRequested
+                        && state.VideoRequest.VideoBitRate.HasValue
+                        && state.VideoStream.BitRate.HasValue
+                        && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value)
+                    {
+                        // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested,
+                        // and the requested video bitrate is higher than source video bitrate.
+                        if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue)
+                        {
+                            state.VideoRequest.MaxWidth = state.VideoStream?.Width;
+                            state.VideoRequest.MaxHeight = state.VideoStream?.Height;
+                        }
+                    }
+                    else
+                    {
+                        var resolution = ResolutionNormalizer.Normalize(
+                            state.VideoStream?.BitRate,
+                            state.VideoStream?.Width,
+                            state.VideoStream?.Height,
+                            state.OutputVideoBitrate.Value,
+                            state.VideoStream?.Codec,
+                            state.OutputVideoCodec,
+                            state.VideoRequest.MaxWidth,
+                            state.VideoRequest.MaxHeight);
+
+                        state.VideoRequest.MaxWidth = resolution.MaxWidth;
+                        state.VideoRequest.MaxHeight = resolution.MaxHeight;
+                    }
                 }
             }
 

+ 22 - 19
Jellyfin.Api/Helpers/TranscodingJobHelper.cs

@@ -145,7 +145,7 @@ namespace Jellyfin.Api.Helpers
             lock (_activeTranscodingJobs)
             {
                 // This is really only needed for HLS.
-                // Progressive streams can stop on their own reliably
+                // Progressive streams can stop on their own reliably.
                 jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList();
             }
 
@@ -241,7 +241,7 @@ namespace Jellyfin.Api.Helpers
             lock (_activeTranscodingJobs)
             {
                 // This is really only needed for HLS.
-                // Progressive streams can stop on their own reliably
+                // Progressive streams can stop on their own reliably.
                 jobs.AddRange(_activeTranscodingJobs.Where(killJob));
             }
 
@@ -304,10 +304,10 @@ namespace Jellyfin.Api.Helpers
 
                         process!.StandardInput.WriteLine("q");
 
-                        // Need to wait because killing is asynchronous
+                        // Need to wait because killing is asynchronous.
                         if (!process.WaitForExit(5000))
                         {
-                            _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path);
+                            _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path);
                             process.Kill();
                         }
                     }
@@ -470,11 +470,11 @@ namespace Jellyfin.Api.Helpers
         }
 
         /// <summary>
-        /// Starts the FFMPEG.
+        /// Starts FFmpeg.
         /// </summary>
         /// <param name="state">The state.</param>
         /// <param name="outputPath">The output path.</param>
-        /// <param name="commandLineArguments">The command line arguments for ffmpeg.</param>
+        /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param>
         /// <param name="request">The <see cref="HttpRequest"/>.</param>
         /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
         /// <param name="cancellationTokenSource">The cancellation token source.</param>
@@ -501,13 +501,13 @@ namespace Jellyfin.Api.Helpers
                 {
                     this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
 
-                    throw new ArgumentException("User does not have access to video transcoding");
+                    throw new ArgumentException("User does not have access to video transcoding.");
                 }
             }
 
             if (string.IsNullOrEmpty(_mediaEncoder.EncoderPath))
             {
-                throw new ArgumentException("FFMPEG path not set.");
+                throw new ArgumentException("FFmpeg path not set.");
             }
 
             var process = new Process
@@ -544,18 +544,20 @@ namespace Jellyfin.Api.Helpers
             var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments;
             _logger.LogInformation(commandLineLogMessage);
 
-            var logFilePrefix = "ffmpeg-transcode";
+            var logFilePrefix = "FFmpeg.Transcode-";
             if (state.VideoRequest != null
                 && EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
             {
                 logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec)
-                    ? "ffmpeg-remux"
-                    : "ffmpeg-directstream";
+                    ? "FFmpeg.Remux-"
+                    : "FFmpeg.DirectStream-";
             }
 
-            var logFilePath = Path.Combine(_serverConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt");
+            var logFilePath = Path.Combine(
+                _serverConfigurationManager.ApplicationPaths.LogDirectoryPath,
+                $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log");
 
-            // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory.
+            // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory.
             Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
 
             var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine);
@@ -569,17 +571,17 @@ namespace Jellyfin.Api.Helpers
             }
             catch (Exception ex)
             {
-                _logger.LogError(ex, "Error starting ffmpeg");
+                _logger.LogError(ex, "Error starting FFmpeg");
 
                 this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
 
                 throw;
             }
 
-            _logger.LogDebug("Launched ffmpeg process");
+            _logger.LogDebug("Launched FFmpeg process");
             state.TranscodingJob = transcodingJob;
 
-            // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback
+            // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback
             _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream);
 
             // Wait for the file to exist before proceeding
@@ -748,11 +750,11 @@ namespace Jellyfin.Api.Helpers
 
             if (process.ExitCode == 0)
             {
-                _logger.LogInformation("FFMpeg exited with code 0");
+                _logger.LogInformation("FFmpeg exited with code 0");
             }
             else
             {
-                _logger.LogError("FFMpeg exited with code {0}", process.ExitCode);
+                _logger.LogError("FFmpeg exited with code {0}", process.ExitCode);
             }
 
             process.Dispose();
@@ -771,8 +773,9 @@ namespace Jellyfin.Api.Helpers
                     new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken },
                     cancellationTokenSource.Token)
                     .ConfigureAwait(false);
+                var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
 
-                _encodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl);
+                _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl);
 
                 if (state.VideoRequest != null)
                 {

+ 49 - 0
Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs

@@ -0,0 +1,49 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+    /// <summary>
+    /// DateTime model binder.
+    /// </summary>
+    public class LegacyDateTimeModelBinder : IModelBinder
+    {
+        // Borrowed from the DateTimeModelBinderProvider
+        private const DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces;
+        private readonly DateTimeModelBinder _defaultModelBinder;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="LegacyDateTimeModelBinder"/> class.
+        /// </summary>
+        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+        public LegacyDateTimeModelBinder(ILoggerFactory loggerFactory)
+        {
+            _defaultModelBinder = new DateTimeModelBinder(SupportedStyles, loggerFactory);
+        }
+
+        /// <inheritdoc />
+        public Task BindModelAsync(ModelBindingContext bindingContext)
+        {
+            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+            if (valueProviderResult.Values.Count == 1)
+            {
+                var dateTimeString = valueProviderResult.FirstValue;
+                // Mark Played Item.
+                if (DateTime.TryParseExact(dateTimeString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime))
+                {
+                    bindingContext.Result = ModelBindingResult.Success(dateTime);
+                }
+                else
+                {
+                    return _defaultModelBinder.BindModelAsync(bindingContext);
+                }
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 47 - 0
Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs

@@ -0,0 +1,47 @@
+using System;
+using System.ComponentModel;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+    /// <summary>
+    /// Nullable enum model binder.
+    /// </summary>
+    public class NullableEnumModelBinder : IModelBinder
+    {
+        private readonly ILogger<NullableEnumModelBinder> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="NullableEnumModelBinder"/> class.
+        /// </summary>
+        /// <param name="logger">Instance of the <see cref="ILogger{NullableEnumModelBinder}"/> interface.</param>
+        public NullableEnumModelBinder(ILogger<NullableEnumModelBinder> logger)
+        {
+            _logger = logger;
+        }
+
+        /// <inheritdoc />
+        public Task BindModelAsync(ModelBindingContext bindingContext)
+        {
+            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+            var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
+            var converter = TypeDescriptor.GetConverter(elementType);
+            if (valueProviderResult.Length != 0)
+            {
+                try
+                {
+                    var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue);
+                    bindingContext.Result = ModelBindingResult.Success(convertedValue);
+                }
+                catch (FormatException e)
+                {
+                    _logger.LogWarning(e, "Error converting value.");
+                }
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 27 - 0
Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs

@@ -0,0 +1,27 @@
+using System;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+    /// <summary>
+    /// Nullable enum model binder provider.
+    /// </summary>
+    public class NullableEnumModelBinderProvider : IModelBinderProvider
+    {
+        /// <inheritdoc />
+        public IModelBinder? GetBinder(ModelBinderProviderContext context)
+        {
+            var nullableType = Nullable.GetUnderlyingType(context.Metadata.ModelType);
+            if (nullableType == null || !nullableType.IsEnum)
+            {
+                // Type isn't nullable or isn't an enum.
+                return null;
+            }
+
+            var logger = context.Services.GetRequiredService<ILogger<NullableEnumModelBinder>>();
+            return new NullableEnumModelBinder(logger);
+        }
+    }
+}

+ 90 - 0
Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs

@@ -0,0 +1,90 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+    /// <summary>
+    /// Comma delimited array model binder.
+    /// Returns an empty array of specified type if there is no query parameter.
+    /// </summary>
+    public class PipeDelimitedArrayModelBinder : IModelBinder
+    {
+        private readonly ILogger<PipeDelimitedArrayModelBinder> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PipeDelimitedArrayModelBinder"/> class.
+        /// </summary>
+        /// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedArrayModelBinder}"/> interface.</param>
+        public PipeDelimitedArrayModelBinder(ILogger<PipeDelimitedArrayModelBinder> logger)
+        {
+            _logger = logger;
+        }
+
+        /// <inheritdoc/>
+        public Task BindModelAsync(ModelBindingContext bindingContext)
+        {
+            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+            var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
+            var converter = TypeDescriptor.GetConverter(elementType);
+
+            if (valueProviderResult.Length > 1)
+            {
+                var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter);
+                bindingContext.Result = ModelBindingResult.Success(typedValues);
+            }
+            else
+            {
+                var value = valueProviderResult.FirstValue;
+
+                if (value != null)
+                {
+                    var splitValues = value.Split('|', StringSplitOptions.RemoveEmptyEntries);
+                    var typedValues = GetParsedResult(splitValues, elementType, converter);
+                    bindingContext.Result = ModelBindingResult.Success(typedValues);
+                }
+                else
+                {
+                    var emptyResult = Array.CreateInstance(elementType, 0);
+                    bindingContext.Result = ModelBindingResult.Success(emptyResult);
+                }
+            }
+
+            return Task.CompletedTask;
+        }
+
+        private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter)
+        {
+            var parsedValues = new object?[values.Count];
+            var convertedCount = 0;
+            for (var i = 0; i < values.Count; i++)
+            {
+                try
+                {
+                    parsedValues[i] = converter.ConvertFromString(values[i].Trim());
+                    convertedCount++;
+                }
+                catch (FormatException e)
+                {
+                    _logger.LogWarning(e, "Error converting value.");
+                }
+            }
+
+            var typedValues = Array.CreateInstance(elementType, convertedCount);
+            var typedValueIndex = 0;
+            for (var i = 0; i < parsedValues.Length; i++)
+            {
+                if (parsedValues[i] != null)
+                {
+                    typedValues.SetValue(parsedValues[i], typedValueIndex);
+                    typedValueIndex++;
+                }
+            }
+
+            return typedValues;
+        }
+    }
+}

+ 6 - 3
Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs

@@ -16,7 +16,8 @@ namespace Jellyfin.Api.Models.LiveTvDtos
         /// <summary>
         /// Gets or sets the channels to return guide information for.
         /// </summary>
-        public string? ChannelIds { get; set; }
+        [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+        public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>();
 
         /// <summary>
         /// Gets or sets optional. Filter by user id.
@@ -115,12 +116,14 @@ namespace Jellyfin.Api.Models.LiveTvDtos
         /// <summary>
         /// Gets or sets the genres to return guide information for.
         /// </summary>
-        public string? Genres { get; set; }
+        [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))]
+        public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>();
 
         /// <summary>
         /// Gets or sets the genre ids to return guide information for.
         /// </summary>
-        public string? GenreIds { get; set; }
+        [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+        public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>();
 
         /// <summary>
         /// Gets or sets include image information in output.

+ 5 - 1
Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs

@@ -1,4 +1,7 @@
 using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using MediaBrowser.Common.Json.Converters;
 
 namespace Jellyfin.Api.Models.PlaylistDtos
 {
@@ -15,7 +18,8 @@ namespace Jellyfin.Api.Models.PlaylistDtos
         /// <summary>
         /// Gets or sets item ids to add to the playlist.
         /// </summary>
-        public string? Ids { get; set; }
+        [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+        public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>();
 
         /// <summary>
         /// Gets or sets the user id.

+ 87 - 0
Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs

@@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Common.Json.Converters;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Session;
+using Newtonsoft.Json;
+
+namespace Jellyfin.Api.Models.SessionDtos
+{
+    /// <summary>
+    /// Client capabilities dto.
+    /// </summary>
+    public class ClientCapabilitiesDto
+    {
+        /// <summary>
+        /// Gets or sets the list of playable media types.
+        /// </summary>
+        public IReadOnlyList<string> PlayableMediaTypes { get; set; } = Array.Empty<string>();
+
+        /// <summary>
+        /// Gets or sets the list of supported commands.
+        /// </summary>
+        [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+        public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } = Array.Empty<GeneralCommandType>();
+
+        /// <summary>
+        /// Gets or sets a value indicating whether session supports media control.
+        /// </summary>
+        public bool SupportsMediaControl { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether session supports content uploading.
+        /// </summary>
+        public bool SupportsContentUploading { get; set; }
+
+        /// <summary>
+        /// Gets or sets the message callback url.
+        /// </summary>
+        public string? MessageCallbackUrl { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether session supports a persistent identifier.
+        /// </summary>
+        public bool SupportsPersistentIdentifier { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether session supports sync.
+        /// </summary>
+        public bool SupportsSync { get; set; }
+
+        /// <summary>
+        /// Gets or sets the device profile.
+        /// </summary>
+        public DeviceProfile? DeviceProfile { get; set; }
+
+        /// <summary>
+        /// Gets or sets the app store url.
+        /// </summary>
+        public string? AppStoreUrl { get; set; }
+
+        /// <summary>
+        /// Gets or sets the icon url.
+        /// </summary>
+        public string? IconUrl { get; set; }
+
+        /// <summary>
+        /// Convert the dto to the full <see cref="ClientCapabilities"/> model.
+        /// </summary>
+        /// <returns>The converted <see cref="ClientCapabilities"/> model.</returns>
+        public ClientCapabilities ToClientCapabilities()
+        {
+            return new ClientCapabilities
+            {
+                PlayableMediaTypes = PlayableMediaTypes,
+                SupportedCommands = SupportedCommands,
+                SupportsMediaControl = SupportsMediaControl,
+                SupportsContentUploading = SupportsContentUploading,
+                MessageCallbackUrl = MessageCallbackUrl,
+                SupportsPersistentIdentifier = SupportsPersistentIdentifier,
+                SupportsSync = SupportsSync,
+                DeviceProfile = DeviceProfile,
+                AppStoreUrl = AppStoreUrl,
+                IconUrl = IconUrl
+            };
+        }
+    }
+}

+ 0 - 44
Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs

@@ -1,44 +0,0 @@
-using System;
-using System.ComponentModel;
-using System.Globalization;
-
-namespace Jellyfin.Api.TypeConverters
-{
-    /// <summary>
-    /// Custom datetime parser.
-    /// </summary>
-    public class DateTimeTypeConverter : TypeConverter
-    {
-        /// <inheritdoc />
-        public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
-        {
-            if (sourceType == typeof(string))
-            {
-                return true;
-            }
-
-            return base.CanConvertFrom(context, sourceType);
-        }
-
-        /// <inheritdoc />
-        public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
-        {
-            if (value is string dateString)
-            {
-                // Mark Played Item.
-                if (DateTime.TryParseExact(dateString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime))
-                {
-                    return dateTime;
-                }
-
-                // Get Activity Logs.
-                if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out dateTime))
-                {
-                    return dateTime;
-                }
-            }
-
-            return base.ConvertFrom(context, culture, value);
-        }
-    }
-}

+ 221 - 0
Jellyfin.Networking/Configuration/NetworkConfiguration.cs

@@ -0,0 +1,221 @@
+#pragma warning disable CA1819 // Properties should not return arrays
+
+using System;
+using MediaBrowser.Model.Configuration;
+
+namespace Jellyfin.Networking.Configuration
+{
+    /// <summary>
+    /// Defines the <see cref="NetworkConfiguration" />.
+    /// </summary>
+    public class NetworkConfiguration
+    {
+        /// <summary>
+        /// The default value for <see cref="HttpServerPortNumber"/>.
+        /// </summary>
+        public const int DefaultHttpPort = 8096;
+
+        /// <summary>
+        /// The default value for <see cref="PublicHttpsPort"/> and <see cref="HttpsPortNumber"/>.
+        /// </summary>
+        public const int DefaultHttpsPort = 8920;
+
+        private string _baseUrl = string.Empty;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the server should force connections over HTTPS.
+        /// </summary>
+        public bool RequireHttps { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at.
+        /// </summary>
+        public string BaseUrl
+        {
+            get => _baseUrl;
+            set
+            {
+                // Normalize the start of the string
+                if (string.IsNullOrWhiteSpace(value))
+                {
+                    // If baseUrl is empty, set an empty prefix string
+                    _baseUrl = string.Empty;
+                    return;
+                }
+
+                if (value[0] != '/')
+                {
+                    // If baseUrl was not configured with a leading slash, append one for consistency
+                    value = "/" + value;
+                }
+
+                // Normalize the end of the string
+                if (value[^1] == '/')
+                {
+                    // If baseUrl was configured with a trailing slash, remove it for consistency
+                    value = value.Remove(value.Length - 1);
+                }
+
+                _baseUrl = value;
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets the public HTTPS port.
+        /// </summary>
+        /// <value>The public HTTPS port.</value>
+        public int PublicHttpsPort { get; set; } = DefaultHttpsPort;
+
+        /// <summary>
+        /// Gets or sets the HTTP server port number.
+        /// </summary>
+        /// <value>The HTTP server port number.</value>
+        public int HttpServerPortNumber { get; set; } = DefaultHttpPort;
+
+        /// <summary>
+        /// Gets or sets the HTTPS server port number.
+        /// </summary>
+        /// <value>The HTTPS server port number.</value>
+        public int HttpsPortNumber { get; set; } = DefaultHttpsPort;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to use HTTPS.
+        /// </summary>
+        /// <remarks>
+        /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be
+        /// provided for <see cref="ServerConfiguration.CertificatePath"/> and <see cref="ServerConfiguration.CertificatePassword"/>.
+        /// </remarks>
+        public bool EnableHttps { get; set; }
+
+        /// <summary>
+        /// Gets or sets the public mapped port.
+        /// </summary>
+        /// <value>The public mapped port.</value>
+        public int PublicPort { get; set; } = DefaultHttpPort;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding.
+        /// </summary>
+        public bool UPnPCreateHttpPortMap { get; set; }
+
+        /// <summary>
+        /// Gets or sets the UDPPortRange.
+        /// </summary>
+        public string UDPPortRange { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether gets or sets IPV6 capability.
+        /// </summary>
+        public bool EnableIPV6 { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether gets or sets IPV4 capability.
+        /// </summary>
+        public bool EnableIPV4 { get; set; } = true;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether detailed SSDP logs are sent to the console/log.
+        /// "Emby.Dlna": "Debug" must be set in logging.default.json for this property to have any effect.
+        /// </summary>
+        public bool EnableSSDPTracing { get; set; }
+
+        /// <summary>
+        /// Gets or sets the SSDPTracingFilter
+        /// Gets or sets a value indicating whether an IP address is to be used to filter the detailed ssdp logs that are being sent to the console/log.
+        /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work.
+        /// </summary>
+        public string SSDPTracingFilter { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the number of times SSDP UDP messages are sent.
+        /// </summary>
+        public int UDPSendCount { get; set; } = 2;
+
+        /// <summary>
+        /// Gets or sets the delay between each groups of SSDP messages (in ms).
+        /// </summary>
+        public int UDPSendDelay { get; set; } = 100;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether address names that match <see cref="VirtualInterfaceNames"/> should be Ignore for the purposes of binding.
+        /// </summary>
+        public bool IgnoreVirtualInterfaces { get; set; } = true;
+
+        /// <summary>
+        /// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. <seealso cref="IgnoreVirtualInterfaces"/>.
+        /// </summary>
+        public string VirtualInterfaceNames { get; set; } = "vEthernet*";
+
+        /// <summary>
+        /// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor.
+        /// </summary>
+        public int GatewayMonitorPeriod { get; set; } = 60;
+
+        /// <summary>
+        /// Gets a value indicating whether multi-socket binding is available.
+        /// </summary>
+        public bool EnableMultiSocketBinding { get; } = true;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether all IPv6 interfaces should be treated as on the internal network.
+        /// Depending on the address range implemented ULA ranges might not be used.
+        /// </summary>
+        public bool TrustAllIP6Interfaces { get; set; }
+
+        /// <summary>
+        /// Gets or sets the ports that HDHomerun uses.
+        /// </summary>
+        public string HDHomerunPortRange { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the PublishedServerUriBySubnet
+        /// Gets or sets PublishedServerUri to advertise for specific subnets.
+        /// </summary>
+        public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>();
+
+        /// <summary>
+        /// Gets or sets a value indicating whether Autodiscovery tracing is enabled.
+        /// </summary>
+        public bool AutoDiscoveryTracing { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether Autodiscovery is enabled.
+        /// </summary>
+        public bool AutoDiscovery { get; set; } = true;
+
+        /// <summary>
+        /// Gets or sets the filter for remote IP connectivity. Used in conjuntion with <seealso cref="IsRemoteIPFilterBlacklist"/>.
+        /// </summary>
+        public string[] RemoteIPFilter { get; set; } = Array.Empty<string>();
+
+        /// <summary>
+        /// Gets or sets a value indicating whether <seealso cref="RemoteIPFilter"/> contains a blacklist or a whitelist. Default is a whitelist.
+        /// </summary>
+        public bool IsRemoteIPFilterBlacklist { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to enable automatic port forwarding.
+        /// </summary>
+        public bool EnableUPnP { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether access outside of the LAN is permitted.
+        /// </summary>
+        public bool EnableRemoteAccess { get; set; } = true;
+
+        /// <summary>
+        /// Gets or sets the subnets that are deemed to make up the LAN.
+        /// </summary>
+        public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>();
+
+        /// <summary>
+        /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used.
+        /// </summary>
+        public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>();
+
+        /// <summary>
+        /// Gets or sets the known proxies.
+        /// </summary>
+        public string[] KnownProxies { get; set; } = Array.Empty<string>();
+    }
+}

+ 21 - 0
Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs

@@ -0,0 +1,21 @@
+using Jellyfin.Networking.Configuration;
+using MediaBrowser.Common.Configuration;
+
+namespace Jellyfin.Networking.Configuration
+{
+    /// <summary>
+    /// Defines the <see cref="NetworkConfigurationExtensions" />.
+    /// </summary>
+    public static class NetworkConfigurationExtensions
+    {
+        /// <summary>
+        /// Retrieves the network configuration.
+        /// </summary>
+        /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
+        /// <returns>The <see cref="NetworkConfiguration"/>.</returns>
+        public static NetworkConfiguration GetNetworkConfiguration(this IConfigurationManager config)
+        {
+            return config.GetConfiguration<NetworkConfiguration>("network");
+        }
+    }
+}

+ 27 - 0
Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs

@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using MediaBrowser.Common.Configuration;
+
+namespace Jellyfin.Networking.Configuration
+{
+    /// <summary>
+    /// Defines the <see cref="NetworkConfigurationFactory" />.
+    /// </summary>
+    public class NetworkConfigurationFactory : IConfigurationFactory
+    {
+        /// <summary>
+        /// The GetConfigurations.
+        /// </summary>
+        /// <returns>The <see cref="IEnumerable{ConfigurationStore}"/>.</returns>
+        public IEnumerable<ConfigurationStore> GetConfigurations()
+        {
+            return new[]
+            {
+                new ConfigurationStore
+                {
+                    Key = "network",
+                    ConfigurationType = typeof(NetworkConfiguration)
+                }
+            };
+        }
+    }
+}

+ 30 - 0
Jellyfin.Networking/Jellyfin.Networking.csproj

@@ -0,0 +1,30 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net5.0</TargetFramework>
+    <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
+    <GenerateDocumentationFile>true</GenerateDocumentationFile>
+    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Compile Include="..\SharedVersion.cs" />
+  </ItemGroup>
+
+  <!-- Code Analyzers-->
+  <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
+    <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
+    <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
+    <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
+    <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
+  </ItemGroup>
+
+  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
+    <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
+    <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
+  </ItemGroup>
+</Project>

+ 234 - 0
Jellyfin.Networking/Manager/INetworkManager.cs

@@ -0,0 +1,234 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Net;
+using System.Net.NetworkInformation;
+using Jellyfin.Networking.Configuration;
+using MediaBrowser.Common.Net;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Networking.Manager
+{
+    /// <summary>
+    /// Interface for the NetworkManager class.
+    /// </summary>
+    public interface INetworkManager
+    {
+        /// <summary>
+        /// Event triggered on network changes.
+        /// </summary>
+        event EventHandler NetworkChanged;
+
+        /// <summary>
+        /// Gets the published server urls list.
+        /// </summary>
+        Dictionary<IPNetAddress, string> PublishedServerUrls { get; }
+
+        /// <summary>
+        /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal.
+        /// </summary>
+        bool TrustAllIP6Interfaces { get; }
+
+        /// <summary>
+        /// Gets the remote address filter.
+        /// </summary>
+        Collection<IPObject> RemoteAddressFilter { get; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether iP6 is enabled.
+        /// </summary>
+        bool IsIP6Enabled { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether iP4 is enabled.
+        /// </summary>
+        bool IsIP4Enabled { get; set; }
+
+        /// <summary>
+        /// Calculates the list of interfaces to use for Kestrel.
+        /// </summary>
+        /// <returns>A Collection{IPObject} object containing all the interfaces to bind.
+        /// If all the interfaces are specified, and none are excluded, it returns zero items
+        /// to represent any address.</returns>
+        /// <param name="individualInterfaces">When false, return <see cref="IPAddress.Any"/> or <see cref="IPAddress.IPv6Any"/> for all interfaces.</param>
+        Collection<IPObject> GetAllBindInterfaces(bool individualInterfaces = false);
+
+        /// <summary>
+        /// Returns a collection containing the loopback interfaces.
+        /// </summary>
+        /// <returns>Collection{IPObject}.</returns>
+        Collection<IPObject> GetLoopbacks();
+
+        /// <summary>
+        /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+        /// If no bind addresses are specified, an internal interface address is selected.
+        /// The priority of selection is as follows:-
+        ///
+        /// The value contained in the startup parameter --published-server-url.
+        ///
+        /// If the user specified custom subnet overrides, the correct subnet for the source address.
+        ///
+        /// If the user specified bind interfaces to use:-
+        ///  The bind interface that contains the source subnet.
+        ///  The first bind interface specified that suits best first the source's endpoint. eg. external or internal.
+        ///
+        /// If the source is from a public subnet address range and the user hasn't specified any bind addresses:-
+        ///  The first public interface that isn't a loopback and contains the source subnet.
+        ///  The first public interface that isn't a loopback. Priority is given to interfaces with gateways.
+        ///  An internal interface if there are no public ip addresses.
+        ///
+        /// If the source is from a private subnet address range and the user hasn't specified any bind addresses:-
+        ///  The first private interface that contains the source subnet.
+        ///  The first private interface that isn't a loopback. Priority is given to interfaces with gateways.
+        ///
+        /// If no interfaces meet any of these criteria, then a loopback address is returned.
+        ///
+        /// Interface that have been specifically excluded from binding are not used in any of the calculations.
+        /// </summary>
+        /// <param name="source">Source of the request.</param>
+        /// <param name="port">Optional port returned, if it's part of an override.</param>
+        /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+        string GetBindInterface(IPObject source, out int? port);
+
+        /// <summary>
+        /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+        /// If no bind addresses are specified, an internal interface address is selected.
+        /// (See <see cref="GetBindInterface(IPObject, out int?)"/>.
+        /// </summary>
+        /// <param name="source">Source of the request.</param>
+        /// <param name="port">Optional port returned, if it's part of an override.</param>
+        /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+        string GetBindInterface(HttpRequest source, out int? port);
+
+        /// <summary>
+        /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+        /// If no bind addresses are specified, an internal interface address is selected.
+        /// (See <see cref="GetBindInterface(IPObject, out int?)"/>.
+        /// </summary>
+        /// <param name="source">IP address of the request.</param>
+        /// <param name="port">Optional port returned, if it's part of an override.</param>
+        /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+        string GetBindInterface(IPAddress source, out int? port);
+
+        /// <summary>
+        /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+        /// If no bind addresses are specified, an internal interface address is selected.
+        /// (See <see cref="GetBindInterface(IPObject, out int?)"/>.
+        /// </summary>
+        /// <param name="source">Source of the request.</param>
+        /// <param name="port">Optional port returned, if it's part of an override.</param>
+        /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+        string GetBindInterface(string source, out int? port);
+
+        /// <summary>
+        /// Checks to see if the ip address is specifically excluded in LocalNetworkAddresses.
+        /// </summary>
+        /// <param name="address">IP address to check.</param>
+        /// <returns>True if it is.</returns>
+        bool IsExcludedInterface(IPAddress address);
+
+        /// <summary>
+        /// Get a list of all the MAC addresses associated with active interfaces.
+        /// </summary>
+        /// <returns>List of MAC addresses.</returns>
+        IReadOnlyCollection<PhysicalAddress> GetMacAddresses();
+
+        /// <summary>
+        /// Checks to see if the IP Address provided matches an interface that has a gateway.
+        /// </summary>
+        /// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param>
+        /// <returns>Result of the check.</returns>
+        bool IsGatewayInterface(IPObject? addressObj);
+
+        /// <summary>
+        /// Checks to see if the IP Address provided matches an interface that has a gateway.
+        /// </summary>
+        /// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param>
+        /// <returns>Result of the check.</returns>
+        bool IsGatewayInterface(IPAddress? addressObj);
+
+        /// <summary>
+        /// Returns true if the address is a private address.
+        /// The config option TrustIP6Interfaces overrides this functions behaviour.
+        /// </summary>
+        /// <param name="address">Address to check.</param>
+        /// <returns>True or False.</returns>
+        bool IsPrivateAddressRange(IPObject address);
+
+        /// <summary>
+        /// Returns true if the address is part of the user defined LAN.
+        /// The config option TrustIP6Interfaces overrides this functions behaviour.
+        /// </summary>
+        /// <param name="address">IP to check.</param>
+        /// <returns>True if endpoint is within the LAN range.</returns>
+        bool IsInLocalNetwork(string address);
+
+        /// <summary>
+        /// Returns true if the address is part of the user defined LAN.
+        /// The config option TrustIP6Interfaces overrides this functions behaviour.
+        /// </summary>
+        /// <param name="address">IP to check.</param>
+        /// <returns>True if endpoint is within the LAN range.</returns>
+        bool IsInLocalNetwork(IPObject address);
+
+        /// <summary>
+        /// Returns true if the address is part of the user defined LAN.
+        /// The config option TrustIP6Interfaces overrides this functions behaviour.
+        /// </summary>
+        /// <param name="address">IP to check.</param>
+        /// <returns>True if endpoint is within the LAN range.</returns>
+        bool IsInLocalNetwork(IPAddress address);
+
+        /// <summary>
+        /// Attempts to convert the token to an IP address, permitting for interface descriptions and indexes.
+        /// eg. "eth1", or "TP-LINK Wireless USB Adapter".
+        /// </summary>
+        /// <param name="token">Token to parse.</param>
+        /// <param name="result">Resultant object's ip addresses, if successful.</param>
+        /// <returns>Success of the operation.</returns>
+        bool TryParseInterface(string token, out Collection<IPObject>? result);
+
+        /// <summary>
+        /// Parses an array of strings into a Collection{IPObject}.
+        /// </summary>
+        /// <param name="values">Values to parse.</param>
+        /// <param name="bracketed">When true, only include values in []. When false, ignore bracketed values.</param>
+        /// <returns>IPCollection object containing the value strings.</returns>
+        Collection<IPObject> CreateIPCollection(string[] values, bool bracketed = false);
+
+        /// <summary>
+        /// Returns all the internal Bind interface addresses.
+        /// </summary>
+        /// <returns>An internal list of interfaces addresses.</returns>
+        Collection<IPObject> GetInternalBindAddresses();
+
+        /// <summary>
+        /// Checks to see if an IP address is still a valid interface address.
+        /// </summary>
+        /// <param name="address">IP address to check.</param>
+        /// <returns>True if it is.</returns>
+        bool IsValidInterfaceAddress(IPAddress address);
+
+        /// <summary>
+        /// Returns true if the IP address is in the excluded list.
+        /// </summary>
+        /// <param name="ip">IP to check.</param>
+        /// <returns>True if excluded.</returns>
+        bool IsExcluded(IPAddress ip);
+
+        /// <summary>
+        /// Returns true if the IP address is in the excluded list.
+        /// </summary>
+        /// <param name="ip">IP to check.</param>
+        /// <returns>True if excluded.</returns>
+        bool IsExcluded(EndPoint ip);
+
+        /// <summary>
+        /// Gets the filtered LAN ip addresses.
+        /// </summary>
+        /// <param name="filter">Optional filter for the list.</param>
+        /// <returns>Returns a filtered list of LAN addresses.</returns>
+        Collection<IPObject> GetFilteredLANSubnets(Collection<IPObject>? filter = null);
+    }
+}

+ 1319 - 0
Jellyfin.Networking/Manager/NetworkManager.cs

@@ -0,0 +1,1319 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+using Jellyfin.Networking.Configuration;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Networking.Manager
+{
+    /// <summary>
+    /// Class to take care of network interface management.
+    /// Note: The normal collection methods and properties will not work with Collection{IPObject}. <see cref="MediaBrowser.Common.Net.NetworkExtensions"/>.
+    /// </summary>
+    public class NetworkManager : INetworkManager, IDisposable
+    {
+        /// <summary>
+        /// Contains the description of the interface along with its index.
+        /// </summary>
+        private readonly Dictionary<string, int> _interfaceNames;
+
+        /// <summary>
+        /// Threading lock for network properties.
+        /// </summary>
+        private readonly object _intLock = new object();
+
+        /// <summary>
+        /// List of all interface addresses and masks.
+        /// </summary>
+        private readonly Collection<IPObject> _interfaceAddresses;
+
+        /// <summary>
+        /// List of all interface MAC addresses.
+        /// </summary>
+        private readonly List<PhysicalAddress> _macAddresses;
+
+        private readonly ILogger<NetworkManager> _logger;
+
+        private readonly IConfigurationManager _configurationManager;
+
+        private readonly object _eventFireLock;
+
+        /// <summary>
+        /// Holds the bind address overrides.
+        /// </summary>
+        private readonly Dictionary<IPNetAddress, string> _publishedServerUrls;
+
+        /// <summary>
+        /// Used to stop "event-racing conditions".
+        /// </summary>
+        private bool _eventfire;
+
+        /// <summary>
+        /// Unfiltered user defined LAN subnets. (<see cref="NetworkConfiguration.LocalNetworkSubnets"/>)
+        /// or internal interface network subnets if undefined by user.
+        /// </summary>
+        private Collection<IPObject> _lanSubnets;
+
+        /// <summary>
+        /// User defined list of subnets to excluded from the LAN.
+        /// </summary>
+        private Collection<IPObject> _excludedSubnets;
+
+        /// <summary>
+        /// List of interface addresses to bind the WS.
+        /// </summary>
+        private Collection<IPObject> _bindAddresses;
+
+        /// <summary>
+        /// List of interface addresses to exclude from bind.
+        /// </summary>
+        private Collection<IPObject> _bindExclusions;
+
+        /// <summary>
+        /// Caches list of all internal filtered interface addresses and masks.
+        /// </summary>
+        private Collection<IPObject> _internalInterfaces;
+
+        /// <summary>
+        /// Flag set when no custom LAN has been defined in the config.
+        /// </summary>
+        private bool _usingPrivateAddresses;
+
+        /// <summary>
+        /// True if this object is disposed.
+        /// </summary>
+        private bool _disposed;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="NetworkManager"/> class.
+        /// </summary>
+        /// <param name="configurationManager">IServerConfigurationManager instance.</param>
+        /// <param name="logger">Logger to use for messages.</param>
+#pragma warning disable CS8618 // Non-nullable field is uninitialized. : Values are set in UpdateSettings function. Compiler doesn't yet recognise this.
+        public NetworkManager(IConfigurationManager configurationManager, ILogger<NetworkManager> logger)
+        {
+            _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+            _configurationManager = configurationManager ?? throw new ArgumentNullException(nameof(configurationManager));
+
+            _interfaceAddresses = new Collection<IPObject>();
+            _macAddresses = new List<PhysicalAddress>();
+            _interfaceNames = new Dictionary<string, int>();
+            _publishedServerUrls = new Dictionary<IPNetAddress, string>();
+            _eventFireLock = new object();
+
+            UpdateSettings(_configurationManager.GetNetworkConfiguration());
+
+            NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged;
+            NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged;
+
+            _configurationManager.NamedConfigurationUpdated += ConfigurationUpdated;
+        }
+#pragma warning restore CS8618 // Non-nullable field is uninitialized.
+
+        /// <summary>
+        /// Event triggered on network changes.
+        /// </summary>
+        public event EventHandler? NetworkChanged;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether testing is taking place.
+        /// </summary>
+        public static string MockNetworkSettings { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether IP6 is enabled.
+        /// </summary>
+        public bool IsIP6Enabled { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether IP4 is enabled.
+        /// </summary>
+        public bool IsIP4Enabled { get; set; }
+
+        /// <inheritdoc/>
+        public Collection<IPObject> RemoteAddressFilter { get; private set; }
+
+        /// <summary>
+        /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal.
+        /// </summary>
+        public bool TrustAllIP6Interfaces { get; internal set; }
+
+        /// <summary>
+        /// Gets the Published server override list.
+        /// </summary>
+        public Dictionary<IPNetAddress, string> PublishedServerUrls => _publishedServerUrls;
+
+        /// <summary>
+        /// Creates a new network collection.
+        /// </summary>
+        /// <param name="source">Items to assign the collection, or null.</param>
+        /// <returns>The collection created.</returns>
+        public static Collection<IPObject> CreateCollection(IEnumerable<IPObject>? source = null)
+        {
+            var result = new Collection<IPObject>();
+            if (source != null)
+            {
+                foreach (var item in source)
+                {
+                    result.AddItem(item);
+                }
+            }
+
+            return result;
+        }
+
+        /// <inheritdoc/>
+        public void Dispose()
+        {
+            Dispose(true);
+            GC.SuppressFinalize(this);
+        }
+
+        /// <inheritdoc/>
+        public IReadOnlyCollection<PhysicalAddress> GetMacAddresses()
+        {
+            // Populated in construction - so always has values.
+            return _macAddresses;
+        }
+
+        /// <inheritdoc/>
+        public bool IsGatewayInterface(IPObject? addressObj)
+        {
+            var address = addressObj?.Address ?? IPAddress.None;
+            return _internalInterfaces.Any(i => i.Address.Equals(address) && i.Tag < 0);
+        }
+
+        /// <inheritdoc/>
+        public bool IsGatewayInterface(IPAddress? addressObj)
+        {
+            return _internalInterfaces.Any(i => i.Address.Equals(addressObj ?? IPAddress.None) && i.Tag < 0);
+        }
+
+        /// <inheritdoc/>
+        public Collection<IPObject> GetLoopbacks()
+        {
+            Collection<IPObject> nc = new Collection<IPObject>();
+            if (IsIP4Enabled)
+            {
+                nc.AddItem(IPAddress.Loopback);
+            }
+
+            if (IsIP6Enabled)
+            {
+                nc.AddItem(IPAddress.IPv6Loopback);
+            }
+
+            return nc;
+        }
+
+        /// <inheritdoc/>
+        public bool IsExcluded(IPAddress ip)
+        {
+            return _excludedSubnets.ContainsAddress(ip);
+        }
+
+        /// <inheritdoc/>
+        public bool IsExcluded(EndPoint ip)
+        {
+            return ip != null && IsExcluded(((IPEndPoint)ip).Address);
+        }
+
+        /// <inheritdoc/>
+        public Collection<IPObject> CreateIPCollection(string[] values, bool bracketed = false)
+        {
+            Collection<IPObject> col = new Collection<IPObject>();
+            if (values == null)
+            {
+                return col;
+            }
+
+            for (int a = 0; a < values.Length; a++)
+            {
+                string v = values[a].Trim();
+
+                try
+                {
+                    if (v.StartsWith('[') && v.EndsWith(']'))
+                    {
+                        if (bracketed)
+                        {
+                            AddToCollection(col, v[1..^1]);
+                        }
+                    }
+                    else if (v.StartsWith('!'))
+                    {
+                        if (bracketed)
+                        {
+                            AddToCollection(col, v[1..]);
+                        }
+                    }
+                    else if (!bracketed)
+                    {
+                        AddToCollection(col, v);
+                    }
+                }
+                catch (ArgumentException e)
+                {
+                    _logger.LogWarning(e, "Ignoring LAN value {value}.", v);
+                }
+            }
+
+            return col;
+        }
+
+        /// <inheritdoc/>
+        public Collection<IPObject> GetAllBindInterfaces(bool individualInterfaces = false)
+        {
+            int count = _bindAddresses.Count;
+
+            if (count == 0)
+            {
+                if (_bindExclusions.Count > 0)
+                {
+                    // Return all the interfaces except the ones specifically excluded.
+                    return _interfaceAddresses.Exclude(_bindExclusions);
+                }
+
+                if (individualInterfaces)
+                {
+                    return new Collection<IPObject>(_interfaceAddresses);
+                }
+
+                // No bind address and no exclusions, so listen on all interfaces.
+                Collection<IPObject> result = new Collection<IPObject>();
+
+                if (IsIP4Enabled)
+                {
+                    result.AddItem(IPAddress.Any);
+                }
+
+                if (IsIP6Enabled)
+                {
+                    result.AddItem(IPAddress.IPv6Any);
+                }
+
+                return result;
+            }
+
+            // Remove any excluded bind interfaces.
+            return _bindAddresses.Exclude(_bindExclusions);
+        }
+
+        /// <inheritdoc/>
+        public string GetBindInterface(string source, out int? port)
+        {
+            if (!string.IsNullOrEmpty(source) && IPHost.TryParse(source, out IPHost host))
+            {
+                return GetBindInterface(host, out port);
+            }
+
+            return GetBindInterface(IPHost.None, out port);
+        }
+
+        /// <inheritdoc/>
+        public string GetBindInterface(IPAddress source, out int? port)
+        {
+            return GetBindInterface(new IPNetAddress(source), out port);
+        }
+
+        /// <inheritdoc/>
+        public string GetBindInterface(HttpRequest source, out int? port)
+        {
+            string result;
+
+            if (source != null && IPHost.TryParse(source.Host.Host, out IPHost host))
+            {
+                result = GetBindInterface(host, out port);
+                port ??= source.Host.Port;
+            }
+            else
+            {
+                result = GetBindInterface(IPNetAddress.None, out port);
+                port ??= source?.Host.Port;
+            }
+
+            return result;
+        }
+
+        /// <inheritdoc/>
+        public string GetBindInterface(IPObject source, out int? port)
+        {
+            port = null;
+            if (source == null)
+            {
+                throw new ArgumentNullException(nameof(source));
+            }
+
+            // Do we have a source?
+            bool haveSource = !source.Address.Equals(IPAddress.None);
+            bool isExternal = false;
+
+            if (haveSource)
+            {
+                if (!IsIP6Enabled && source.AddressFamily == AddressFamily.InterNetworkV6)
+                {
+                    _logger.LogWarning("IPv6 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected.");
+                }
+
+                if (!IsIP4Enabled && source.AddressFamily == AddressFamily.InterNetwork)
+                {
+                    _logger.LogWarning("IPv4 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected.");
+                }
+
+                isExternal = !IsInLocalNetwork(source);
+
+                if (MatchesPublishedServerUrl(source, isExternal, out string res, out port))
+                {
+                    _logger.LogInformation("{Source}: Using BindAddress {Address}:{Port}", source, res, port);
+                    return res;
+                }
+            }
+
+            _logger.LogDebug("GetBindInterface: Source: {HaveSource}, External: {IsExternal}:", haveSource, isExternal);
+
+            // No preference given, so move on to bind addresses.
+            if (MatchesBindInterface(source, isExternal, out string result))
+            {
+                return result;
+            }
+
+            if (isExternal && MatchesExternalInterface(source, out result))
+            {
+                return result;
+            }
+
+            // Get the first LAN interface address that isn't a loopback.
+            var interfaces = CreateCollection(_interfaceAddresses
+                .Exclude(_bindExclusions)
+                .Where(p => IsInLocalNetwork(p))
+                .OrderBy(p => p.Tag));
+
+            if (interfaces.Count > 0)
+            {
+                if (haveSource)
+                {
+                    // Does the request originate in one of the interface subnets?
+                    // (For systems with multiple internal network cards, and multiple subnets)
+                    foreach (var intf in interfaces)
+                    {
+                        if (intf.Contains(source))
+                        {
+                            result = FormatIP6String(intf.Address);
+                            _logger.LogDebug("{Source}: GetBindInterface: Has source, matched best internal interface on range. {Result}", source, result);
+                            return result;
+                        }
+                    }
+                }
+
+                result = FormatIP6String(interfaces.First().Address);
+                _logger.LogDebug("{Source}: GetBindInterface: Matched first internal interface. {Result}", source, result);
+                return result;
+            }
+
+            // There isn't any others, so we'll use the loopback.
+            result = IsIP6Enabled ? "::" : "127.0.0.1";
+            _logger.LogWarning("{Source}: GetBindInterface: Loopback {Result} returned.", source, result);
+            return result;
+        }
+
+        /// <inheritdoc/>
+        public Collection<IPObject> GetInternalBindAddresses()
+        {
+            int count = _bindAddresses.Count;
+
+            if (count == 0)
+            {
+                if (_bindExclusions.Count > 0)
+                {
+                    // Return all the internal interfaces except the ones excluded.
+                    return CreateCollection(_internalInterfaces.Where(p => !_bindExclusions.ContainsAddress(p)));
+                }
+
+                // No bind address, so return all internal interfaces.
+                return CreateCollection(_internalInterfaces.Where(p => !p.IsLoopback()));
+            }
+
+            return new Collection<IPObject>(_bindAddresses);
+        }
+
+        /// <inheritdoc/>
+        public bool IsInLocalNetwork(IPObject address)
+        {
+            if (address == null)
+            {
+                throw new ArgumentNullException(nameof(address));
+            }
+
+            if (address.Equals(IPAddress.None))
+            {
+                return false;
+            }
+
+            // See conversation at https://github.com/jellyfin/jellyfin/pull/3515.
+            if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6)
+            {
+                return true;
+            }
+
+            // As private addresses can be redefined by Configuration.LocalNetworkAddresses
+            return _lanSubnets.ContainsAddress(address) && !_excludedSubnets.ContainsAddress(address);
+        }
+
+        /// <inheritdoc/>
+        public bool IsInLocalNetwork(string address)
+        {
+            if (IPHost.TryParse(address, out IPHost ep))
+            {
+                return _lanSubnets.ContainsAddress(ep) && !_excludedSubnets.ContainsAddress(ep);
+            }
+
+            return false;
+        }
+
+        /// <inheritdoc/>
+        public bool IsInLocalNetwork(IPAddress address)
+        {
+            if (address == null)
+            {
+                throw new ArgumentNullException(nameof(address));
+            }
+
+            // See conversation at https://github.com/jellyfin/jellyfin/pull/3515.
+            if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6)
+            {
+                return true;
+            }
+
+            // As private addresses can be redefined by Configuration.LocalNetworkAddresses
+            return _lanSubnets.ContainsAddress(address) && !_excludedSubnets.ContainsAddress(address);
+        }
+
+        /// <inheritdoc/>
+        public bool IsPrivateAddressRange(IPObject address)
+        {
+            if (address == null)
+            {
+                throw new ArgumentNullException(nameof(address));
+            }
+
+            // See conversation at https://github.com/jellyfin/jellyfin/pull/3515.
+            if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6)
+            {
+                return true;
+            }
+            else
+            {
+                return address.IsPrivateAddressRange();
+            }
+        }
+
+        /// <inheritdoc/>
+        public bool IsExcludedInterface(IPAddress address)
+        {
+            return _bindExclusions.ContainsAddress(address);
+        }
+
+        /// <inheritdoc/>
+        public Collection<IPObject> GetFilteredLANSubnets(Collection<IPObject>? filter = null)
+        {
+            if (filter == null)
+            {
+                return _lanSubnets.Exclude(_excludedSubnets).AsNetworks();
+            }
+
+            return _lanSubnets.Exclude(filter);
+        }
+
+        /// <inheritdoc/>
+        public bool IsValidInterfaceAddress(IPAddress address)
+        {
+            return _interfaceAddresses.ContainsAddress(address);
+        }
+
+        /// <inheritdoc/>
+        public bool TryParseInterface(string token, out Collection<IPObject>? result)
+        {
+            result = null;
+            if (string.IsNullOrEmpty(token))
+            {
+                return false;
+            }
+
+            if (_interfaceNames != null && _interfaceNames.TryGetValue(token.ToLower(CultureInfo.InvariantCulture), out int index))
+            {
+                result = new Collection<IPObject>();
+
+                _logger.LogInformation("Interface {Token} used in settings. Using its interface addresses.", token);
+
+                // Replace interface tags with the interface IP's.
+                foreach (IPNetAddress iface in _interfaceAddresses)
+                {
+                    if (Math.Abs(iface.Tag) == index
+                        && ((IsIP4Enabled && iface.Address.AddressFamily == AddressFamily.InterNetwork)
+                            || (IsIP6Enabled && iface.Address.AddressFamily == AddressFamily.InterNetworkV6)))
+                    {
+                        result.AddItem(iface);
+                    }
+                }
+
+                return true;
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Reloads all settings and re-initialises the instance.
+        /// </summary>
+        /// <param name="configuration">The <see cref="NetworkConfiguration"/> to use.</param>
+        public void UpdateSettings(object configuration)
+        {
+            NetworkConfiguration config = (NetworkConfiguration)configuration ?? throw new ArgumentNullException(nameof(configuration));
+
+            IsIP4Enabled = Socket.OSSupportsIPv4 && config.EnableIPV4;
+            IsIP6Enabled = Socket.OSSupportsIPv6 && config.EnableIPV6;
+
+            if (!IsIP6Enabled && !IsIP4Enabled)
+            {
+                _logger.LogError("IPv4 and IPv6 cannot both be disabled.");
+                IsIP4Enabled = true;
+            }
+
+            TrustAllIP6Interfaces = config.TrustAllIP6Interfaces;
+            // UdpHelper.EnableMultiSocketBinding = config.EnableMultiSocketBinding;
+
+            if (string.IsNullOrEmpty(MockNetworkSettings))
+            {
+                InitialiseInterfaces();
+            }
+            else // Used in testing only.
+            {
+                // Format is <IPAddress>,<Index>,<Name>: <next interface>. Set index to -ve to simulate a gateway.
+                var interfaceList = MockNetworkSettings.Split(':');
+                foreach (var details in interfaceList)
+                {
+                    var parts = details.Split(',');
+                    var address = IPNetAddress.Parse(parts[0]);
+                    var index = int.Parse(parts[1], CultureInfo.InvariantCulture);
+                    address.Tag = index;
+                    _interfaceAddresses.AddItem(address);
+                    _interfaceNames.Add(parts[2], Math.Abs(index));
+                }
+            }
+
+            InitialiseLAN(config);
+            InitialiseBind(config);
+            InitialiseRemote(config);
+            InitialiseOverrides(config);
+        }
+
+        /// <summary>
+        /// Protected implementation of Dispose pattern.
+        /// </summary>
+        /// <param name="disposing"><c>True</c> to dispose the managed state.</param>
+        protected virtual void Dispose(bool disposing)
+        {
+            if (!_disposed)
+            {
+                if (disposing)
+                {
+                    _configurationManager.NamedConfigurationUpdated -= ConfigurationUpdated;
+                    NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged;
+                    NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged;
+                }
+
+                _disposed = true;
+            }
+        }
+
+        /// <summary>
+        /// Tries to identify the string and return an object of that class.
+        /// </summary>
+        /// <param name="addr">String to parse.</param>
+        /// <param name="result">IPObject to return.</param>
+        /// <returns><c>true</c> if the value parsed successfully, <c>false</c> otherwise.</returns>
+        private static bool TryParse(string addr, out IPObject result)
+        {
+            if (!string.IsNullOrEmpty(addr))
+            {
+                // Is it an IP address
+                if (IPNetAddress.TryParse(addr, out IPNetAddress nw))
+                {
+                    result = nw;
+                    return true;
+                }
+
+                if (IPHost.TryParse(addr, out IPHost h))
+                {
+                    result = h;
+                    return true;
+                }
+            }
+
+            result = IPNetAddress.None;
+            return false;
+        }
+
+        /// <summary>
+        /// Converts an IPAddress into a string.
+        /// Ipv6 addresses are returned in [ ], with their scope removed.
+        /// </summary>
+        /// <param name="address">Address to convert.</param>
+        /// <returns>URI safe conversion of the address.</returns>
+        private static string FormatIP6String(IPAddress address)
+        {
+            var str = address.ToString();
+            if (address.AddressFamily == AddressFamily.InterNetworkV6)
+            {
+                int i = str.IndexOf("%", StringComparison.OrdinalIgnoreCase);
+
+                if (i != -1)
+                {
+                    str = str.Substring(0, i);
+                }
+
+                return $"[{str}]";
+            }
+
+            return str;
+        }
+
+        private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt)
+        {
+            if (evt.Key.Equals("network", StringComparison.Ordinal))
+            {
+                UpdateSettings((NetworkConfiguration)evt.NewConfiguration);
+            }
+        }
+
+        /// <summary>
+        /// Checks the string to see if it matches any interface names.
+        /// </summary>
+        /// <param name="token">String to check.</param>
+        /// <param name="index">Interface index number.</param>
+        /// <returns><c>true</c> if an interface name matches the token, <c>False</c> otherwise.</returns>
+        private bool IsInterface(string token, out int index)
+        {
+            index = -1;
+
+            // Is it the name of an interface (windows) eg, Wireless LAN adapter Wireless Network Connection 1.
+            // Null check required here for automated testing.
+            if (_interfaceNames != null && token.Length > 1)
+            {
+                bool partial = token[^1] == '*';
+                if (partial)
+                {
+                    token = token[0..^1];
+                }
+
+                foreach ((string interfc, int interfcIndex) in _interfaceNames)
+                {
+                    if ((!partial && string.Equals(interfc, token, StringComparison.OrdinalIgnoreCase))
+                        || (partial && interfc.StartsWith(token, true, CultureInfo.InvariantCulture)))
+                    {
+                        index = interfcIndex;
+                        return true;
+                    }
+                }
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Parses a string and adds it into the the collection, replacing any interface references.
+        /// </summary>
+        /// <param name="col"><see cref="Collection{IPObject}"/>Collection.</param>
+        /// <param name="token">String value to parse.</param>
+        private void AddToCollection(Collection<IPObject> col, string token)
+        {
+            // Is it the name of an interface (windows) eg, Wireless LAN adapter Wireless Network Connection 1.
+            // Null check required here for automated testing.
+            if (IsInterface(token, out int index))
+            {
+                _logger.LogInformation("Interface {Token} used in settings. Using its interface addresses.", token);
+
+                // Replace interface tags with the interface IP's.
+                foreach (IPNetAddress iface in _interfaceAddresses)
+                {
+                    if (Math.Abs(iface.Tag) == index
+                        && ((IsIP4Enabled && iface.Address.AddressFamily == AddressFamily.InterNetwork)
+                            || (IsIP6Enabled && iface.Address.AddressFamily == AddressFamily.InterNetworkV6)))
+                    {
+                        col.AddItem(iface);
+                    }
+                }
+            }
+            else if (TryParse(token, out IPObject obj))
+            {
+                if (!IsIP6Enabled)
+                {
+                    // Remove IP6 addresses from multi-homed IPHosts.
+                    obj.Remove(AddressFamily.InterNetworkV6);
+                    if (!obj.IsIP6())
+                    {
+                        col.AddItem(obj);
+                    }
+                }
+                else if (!IsIP4Enabled)
+                {
+                    // Remove IP4 addresses from multi-homed IPHosts.
+                    obj.Remove(AddressFamily.InterNetwork);
+                    if (obj.IsIP6())
+                    {
+                        col.AddItem(obj);
+                    }
+                }
+                else
+                {
+                    col.AddItem(obj);
+                }
+            }
+            else
+            {
+                _logger.LogDebug("Invalid or unknown network {Token}.", token);
+            }
+        }
+
+        /// <summary>
+        /// Handler for network change events.
+        /// </summary>
+        /// <param name="sender">Sender.</param>
+        /// <param name="e">A <see cref="NetworkAvailabilityEventArgs"/> containing network availability information.</param>
+        private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e)
+        {
+            _logger.LogDebug("Network availability changed.");
+            OnNetworkChanged();
+        }
+
+        /// <summary>
+        /// Handler for network change events.
+        /// </summary>
+        /// <param name="sender">Sender.</param>
+        /// <param name="e">An <see cref="EventArgs"/>.</param>
+        private void OnNetworkAddressChanged(object? sender, EventArgs e)
+        {
+            _logger.LogDebug("Network address change detected.");
+            OnNetworkChanged();
+        }
+
+        /// <summary>
+        /// Async task that waits for 2 seconds before re-initialising the settings, as typically these events fire multiple times in succession.
+        /// </summary>
+        /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+        private async Task OnNetworkChangeAsync()
+        {
+            try
+            {
+                await Task.Delay(2000).ConfigureAwait(false);
+                InitialiseInterfaces();
+                // Recalculate LAN caches.
+                InitialiseLAN(_configurationManager.GetNetworkConfiguration());
+
+                NetworkChanged?.Invoke(this, EventArgs.Empty);
+            }
+            finally
+            {
+                _eventfire = false;
+            }
+        }
+
+        /// <summary>
+        /// Triggers our event, and re-loads interface information.
+        /// </summary>
+        private void OnNetworkChanged()
+        {
+            lock (_eventFireLock)
+            {
+                if (!_eventfire)
+                {
+                    _logger.LogDebug("Network Address Change Event.");
+                    // As network events tend to fire one after the other only fire once every second.
+                    _eventfire = true;
+                    OnNetworkChangeAsync().GetAwaiter().GetResult();
+                }
+            }
+        }
+
+        /// <summary>
+        /// Parses the user defined overrides into the dictionary object.
+        /// Overrides are the equivalent of localised publishedServerUrl, enabling
+        /// different addresses to be advertised over different subnets.
+        /// format is subnet=ipaddress|host|uri
+        /// when subnet = 0.0.0.0, any external address matches.
+        /// </summary>
+        private void InitialiseOverrides(NetworkConfiguration config)
+        {
+            lock (_intLock)
+            {
+                _publishedServerUrls.Clear();
+                string[] overrides = config.PublishedServerUriBySubnet;
+                if (overrides == null)
+                {
+                    return;
+                }
+
+                foreach (var entry in overrides)
+                {
+                    var parts = entry.Split('=');
+                    if (parts.Length != 2)
+                    {
+                        _logger.LogError("Unable to parse bind override: {Entry}", entry);
+                    }
+                    else
+                    {
+                        var replacement = parts[1].Trim();
+                        if (string.Equals(parts[0], "remaining", StringComparison.OrdinalIgnoreCase))
+                        {
+                            _publishedServerUrls[new IPNetAddress(IPAddress.Broadcast)] = replacement;
+                        }
+                        else if (string.Equals(parts[0], "external", StringComparison.OrdinalIgnoreCase))
+                        {
+                            _publishedServerUrls[new IPNetAddress(IPAddress.Any)] = replacement;
+                        }
+                        else if (TryParseInterface(parts[0], out Collection<IPObject>? addresses) && addresses != null)
+                        {
+                            foreach (IPNetAddress na in addresses)
+                            {
+                                _publishedServerUrls[na] = replacement;
+                            }
+                        }
+                        else if (IPNetAddress.TryParse(parts[0], out IPNetAddress result))
+                        {
+                            _publishedServerUrls[result] = replacement;
+                        }
+                        else
+                        {
+                            _logger.LogError("Unable to parse bind ip address. {Parts}", parts[1]);
+                        }
+                    }
+                }
+            }
+        }
+
+        /// <summary>
+        /// Initialises the network bind addresses.
+        /// </summary>
+        private void InitialiseBind(NetworkConfiguration config)
+        {
+            lock (_intLock)
+            {
+                string[] lanAddresses = config.LocalNetworkAddresses;
+
+                // TODO: remove when bug fixed: https://github.com/jellyfin/jellyfin-web/issues/1334
+
+                if (lanAddresses.Length == 1 && lanAddresses[0].IndexOf(',', StringComparison.OrdinalIgnoreCase) != -1)
+                {
+                    lanAddresses = lanAddresses[0].Split(',');
+                }
+
+                // TODO: end fix: https://github.com/jellyfin/jellyfin-web/issues/1334
+
+                // Add virtual machine interface names to the list of bind exclusions, so that they are auto-excluded.
+                if (config.IgnoreVirtualInterfaces)
+                {
+                    var virtualInterfaceNames = config.VirtualInterfaceNames.Split(',');
+                    var newList = new string[lanAddresses.Length + virtualInterfaceNames.Length];
+                    Array.Copy(lanAddresses, newList, lanAddresses.Length);
+                    Array.Copy(virtualInterfaceNames, 0, newList, lanAddresses.Length, virtualInterfaceNames.Length);
+                    lanAddresses = newList;
+                }
+
+                // Read and parse bind addresses and exclusions, removing ones that don't exist.
+                _bindAddresses = CreateIPCollection(lanAddresses).Union(_interfaceAddresses);
+                _bindExclusions = CreateIPCollection(lanAddresses, true).Union(_interfaceAddresses);
+                _logger.LogInformation("Using bind addresses: {0}", _bindAddresses.AsString());
+                _logger.LogInformation("Using bind exclusions: {0}", _bindExclusions.AsString());
+            }
+        }
+
+        /// <summary>
+        /// Initialises the remote address values.
+        /// </summary>
+        private void InitialiseRemote(NetworkConfiguration config)
+        {
+            lock (_intLock)
+            {
+                RemoteAddressFilter = CreateIPCollection(config.RemoteIPFilter);
+            }
+        }
+
+        /// <summary>
+        /// Initialises internal LAN cache settings.
+        /// </summary>
+        private void InitialiseLAN(NetworkConfiguration config)
+        {
+            lock (_intLock)
+            {
+                _logger.LogDebug("Refreshing LAN information.");
+
+                // Get config options.
+                string[] subnets = config.LocalNetworkSubnets;
+
+                // Create lists from user settings.
+
+                _lanSubnets = CreateIPCollection(subnets);
+                _excludedSubnets = CreateIPCollection(subnets, true).AsNetworks();
+
+                // If no LAN addresses are specified - all private subnets are deemed to be the LAN
+                _usingPrivateAddresses = _lanSubnets.Count == 0;
+
+                // NOTE: The order of the commands generating the collection in this statement matters.
+                // Altering the order will cause the collections to be created incorrectly.
+                if (_usingPrivateAddresses)
+                {
+                    _logger.LogDebug("Using LAN interface addresses as user provided no LAN details.");
+                    // Internal interfaces must be private and not excluded.
+                    _internalInterfaces = CreateCollection(_interfaceAddresses.Where(i => IsPrivateAddressRange(i) && !_excludedSubnets.ContainsAddress(i)));
+
+                    // Subnets are the same as the calculated internal interface.
+                    _lanSubnets = new Collection<IPObject>();
+
+                    // We must listen on loopback for LiveTV to function regardless of the settings.
+                    if (IsIP6Enabled)
+                    {
+                        _lanSubnets.AddItem(IPNetAddress.IP6Loopback);
+                        _lanSubnets.AddItem(IPNetAddress.Parse("fc00::/7")); // ULA
+                        _lanSubnets.AddItem(IPNetAddress.Parse("fe80::/10")); // Site local
+                    }
+
+                    if (IsIP4Enabled)
+                    {
+                        _lanSubnets.AddItem(IPNetAddress.IP4Loopback);
+                        _lanSubnets.AddItem(IPNetAddress.Parse("10.0.0.0/8"));
+                        _lanSubnets.AddItem(IPNetAddress.Parse("172.16.0.0/12"));
+                        _lanSubnets.AddItem(IPNetAddress.Parse("192.168.0.0/16"));
+                    }
+                }
+                else
+                {
+                    // We must listen on loopback for LiveTV to function regardless of the settings.
+                    if (IsIP6Enabled)
+                    {
+                        _lanSubnets.AddItem(IPNetAddress.IP6Loopback);
+                    }
+
+                    if (IsIP4Enabled)
+                    {
+                        _lanSubnets.AddItem(IPNetAddress.IP4Loopback);
+                    }
+
+                    // Internal interfaces must be private, not excluded and part of the LocalNetworkSubnet.
+                    _internalInterfaces = CreateCollection(_interfaceAddresses.Where(i => IsInLocalNetwork(i)));
+                }
+
+                _logger.LogInformation("Defined LAN addresses : {0}", _lanSubnets.AsString());
+                _logger.LogInformation("Defined LAN exclusions : {0}", _excludedSubnets.AsString());
+                _logger.LogInformation("Using LAN addresses: {0}", _lanSubnets.Exclude(_excludedSubnets).AsNetworks().AsString());
+            }
+        }
+
+        /// <summary>
+        /// Generate a list of all the interface ip addresses and submasks where that are in the active/unknown state.
+        /// Generate a list of all active mac addresses that aren't loopback addresses.
+        /// </summary>
+        private void InitialiseInterfaces()
+        {
+            lock (_intLock)
+            {
+                _logger.LogDebug("Refreshing interfaces.");
+
+                _interfaceNames.Clear();
+                _interfaceAddresses.Clear();
+                _macAddresses.Clear();
+
+                try
+                {
+                    IEnumerable<NetworkInterface> nics = NetworkInterface.GetAllNetworkInterfaces()
+                        .Where(i => i.SupportsMulticast && i.OperationalStatus == OperationalStatus.Up);
+
+                    foreach (NetworkInterface adapter in nics)
+                    {
+                        try
+                        {
+                            IPInterfaceProperties ipProperties = adapter.GetIPProperties();
+                            PhysicalAddress mac = adapter.GetPhysicalAddress();
+
+                            // populate mac list
+                            if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && mac != null && mac != PhysicalAddress.None)
+                            {
+                                _macAddresses.Add(mac);
+                            }
+
+                            // populate interface address list
+                            foreach (UnicastIPAddressInformation info in ipProperties.UnicastAddresses)
+                            {
+                                if (IsIP4Enabled && info.Address.AddressFamily == AddressFamily.InterNetwork)
+                                {
+                                    IPNetAddress nw = new IPNetAddress(info.Address, IPObject.MaskToCidr(info.IPv4Mask))
+                                    {
+                                        // Keep the number of gateways on this interface, along with its index.
+                                        Tag = ipProperties.GetIPv4Properties().Index
+                                    };
+
+                                    int tag = nw.Tag;
+                                    if (ipProperties.GatewayAddresses.Count > 0 && !nw.IsLoopback())
+                                    {
+                                        // -ve Tags signify the interface has a gateway.
+                                        nw.Tag *= -1;
+                                    }
+
+                                    _interfaceAddresses.AddItem(nw);
+
+                                    // Store interface name so we can use the name in Collections.
+                                    _interfaceNames[adapter.Description.ToLower(CultureInfo.InvariantCulture)] = tag;
+                                    _interfaceNames["eth" + tag.ToString(CultureInfo.InvariantCulture)] = tag;
+                                }
+                                else if (IsIP6Enabled && info.Address.AddressFamily == AddressFamily.InterNetworkV6)
+                                {
+                                    IPNetAddress nw = new IPNetAddress(info.Address, (byte)info.PrefixLength)
+                                    {
+                                        // Keep the number of gateways on this interface, along with its index.
+                                        Tag = ipProperties.GetIPv6Properties().Index
+                                    };
+
+                                    int tag = nw.Tag;
+                                    if (ipProperties.GatewayAddresses.Count > 0 && !nw.IsLoopback())
+                                    {
+                                        // -ve Tags signify the interface has a gateway.
+                                        nw.Tag *= -1;
+                                    }
+
+                                    _interfaceAddresses.AddItem(nw);
+
+                                    // Store interface name so we can use the name in Collections.
+                                    _interfaceNames[adapter.Description.ToLower(CultureInfo.InvariantCulture)] = tag;
+                                    _interfaceNames["eth" + tag.ToString(CultureInfo.InvariantCulture)] = tag;
+                                }
+                            }
+                        }
+#pragma warning disable CA1031 // Do not catch general exception types
+                        catch (Exception ex)
+                        {
+                            // Ignore error, and attempt to continue.
+                            _logger.LogError(ex, "Error encountered parsing interfaces.");
+                        }
+#pragma warning restore CA1031 // Do not catch general exception types
+                    }
+
+                    _logger.LogDebug("Discovered {0} interfaces.", _interfaceAddresses.Count);
+                    _logger.LogDebug("Interfaces addresses : {0}", _interfaceAddresses.AsString());
+
+                    // If for some reason we don't have an interface info, resolve our DNS name.
+                    if (_interfaceAddresses.Count == 0)
+                    {
+                        _logger.LogError("No interfaces information available. Resolving DNS name.");
+                        IPHost host = new IPHost(Dns.GetHostName());
+                        foreach (var a in host.GetAddresses())
+                        {
+                            _interfaceAddresses.AddItem(a);
+                        }
+
+                        if (_interfaceAddresses.Count == 0)
+                        {
+                            _logger.LogWarning("No interfaces information available. Using loopback.");
+                            // Last ditch attempt - use loopback address.
+                            _interfaceAddresses.AddItem(IPNetAddress.IP4Loopback);
+                            if (IsIP6Enabled)
+                            {
+                                _interfaceAddresses.AddItem(IPNetAddress.IP6Loopback);
+                            }
+                        }
+                    }
+                }
+                catch (NetworkInformationException ex)
+                {
+                    _logger.LogError(ex, "Error in InitialiseInterfaces.");
+                }
+            }
+        }
+
+        /// <summary>
+        /// Attempts to match the source against a user defined bind interface.
+        /// </summary>
+        /// <param name="source">IP source address to use.</param>
+        /// <param name="isInExternalSubnet">True if the source is in the external subnet.</param>
+        /// <param name="bindPreference">The published server url that matches the source address.</param>
+        /// <param name="port">The resultant port, if one exists.</param>
+        /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns>
+        private bool MatchesPublishedServerUrl(IPObject source, bool isInExternalSubnet, out string bindPreference, out int? port)
+        {
+            bindPreference = string.Empty;
+            port = null;
+
+            // Check for user override.
+            foreach (var addr in _publishedServerUrls)
+            {
+                // Remaining. Match anything.
+                if (addr.Key.Address.Equals(IPAddress.Broadcast))
+                {
+                    bindPreference = addr.Value;
+                    break;
+                }
+                else if ((addr.Key.Address.Equals(IPAddress.Any) || addr.Key.Address.Equals(IPAddress.IPv6Any)) && isInExternalSubnet)
+                {
+                    // External.
+                    bindPreference = addr.Value;
+                    break;
+                }
+                else if (addr.Key.Contains(source))
+                {
+                    // Match ip address.
+                    bindPreference = addr.Value;
+                    break;
+                }
+            }
+
+            if (string.IsNullOrEmpty(bindPreference))
+            {
+                return false;
+            }
+
+            // Has it got a port defined?
+            var parts = bindPreference.Split(':');
+            if (parts.Length > 1)
+            {
+                if (int.TryParse(parts[1], out int p))
+                {
+                    bindPreference = parts[0];
+                    port = p;
+                }
+            }
+
+            return true;
+        }
+
+        /// <summary>
+        /// Attempts to match the source against a user defined bind interface.
+        /// </summary>
+        /// <param name="source">IP source address to use.</param>
+        /// <param name="isInExternalSubnet">True if the source is in the external subnet.</param>
+        /// <param name="result">The result, if a match is found.</param>
+        /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns>
+        private bool MatchesBindInterface(IPObject source, bool isInExternalSubnet, out string result)
+        {
+            result = string.Empty;
+            var addresses = _bindAddresses.Exclude(_bindExclusions);
+
+            int count = addresses.Count;
+            if (count == 1 && (_bindAddresses[0].Equals(IPAddress.Any) || _bindAddresses[0].Equals(IPAddress.IPv6Any)))
+            {
+                // Ignore IPAny addresses.
+                count = 0;
+            }
+
+            if (count != 0)
+            {
+                // Check to see if any of the bind interfaces are in the same subnet.
+
+                IPAddress? defaultGateway = null;
+                IPAddress? bindAddress = null;
+
+                if (isInExternalSubnet)
+                {
+                    // Find all external bind addresses. Store the default gateway, but check to see if there is a better match first.
+                    foreach (var addr in addresses.OrderBy(p => p.Tag))
+                    {
+                        if (defaultGateway == null && !IsInLocalNetwork(addr))
+                        {
+                            defaultGateway = addr.Address;
+                        }
+
+                        if (bindAddress == null && addr.Contains(source))
+                        {
+                            bindAddress = addr.Address;
+                        }
+
+                        if (defaultGateway != null && bindAddress != null)
+                        {
+                            break;
+                        }
+                    }
+                }
+                else
+                {
+                    // Look for the best internal address.
+                    bindAddress = addresses
+                        .Where(p => IsInLocalNetwork(p) && (p.Contains(source) || p.Equals(IPAddress.None)))
+                        .OrderBy(p => p.Tag)
+                        .FirstOrDefault()?.Address;
+                }
+
+                if (bindAddress != null)
+                {
+                    result = FormatIP6String(bindAddress);
+                    _logger.LogDebug("{Source}: GetBindInterface: Has source, found a match bind interface subnets. {Result}", source, result);
+                    return true;
+                }
+
+                if (isInExternalSubnet && defaultGateway != null)
+                {
+                    result = FormatIP6String(defaultGateway);
+                    _logger.LogDebug("{Source}: GetBindInterface: Using first user defined external interface. {Result}", source, result);
+                    return true;
+                }
+
+                result = FormatIP6String(addresses[0].Address);
+                _logger.LogDebug("{Source}: GetBindInterface: Selected first user defined interface. {Result}", source, result);
+
+                if (isInExternalSubnet)
+                {
+                    _logger.LogWarning("{Source}: External request received, however, only an internal interface bind found.", source);
+                }
+
+                return true;
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Attempts to match the source against an external interface.
+        /// </summary>
+        /// <param name="source">IP source address to use.</param>
+        /// <param name="result">The result, if a match is found.</param>
+        /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns>
+        private bool MatchesExternalInterface(IPObject source, out string result)
+        {
+            result = string.Empty;
+            // Get the first WAN interface address that isn't a loopback.
+            var extResult = _interfaceAddresses
+                .Exclude(_bindExclusions)
+                .Where(p => !IsInLocalNetwork(p))
+                .OrderBy(p => p.Tag);
+
+            if (extResult.Any())
+            {
+                // Does the request originate in one of the interface subnets?
+                // (For systems with multiple internal network cards, and multiple subnets)
+                foreach (var intf in extResult)
+                {
+                    if (!IsInLocalNetwork(intf) && intf.Contains(source))
+                    {
+                        result = FormatIP6String(intf.Address);
+                        _logger.LogDebug("{Source}: GetBindInterface: Selected best external on interface on range. {Result}", source, result);
+                        return true;
+                    }
+                }
+
+                result = FormatIP6String(extResult.First().Address);
+                _logger.LogDebug("{Source}: GetBindInterface: Selected first external interface. {Result}", source, result);
+                return true;
+            }
+
+            // Have to return something, so return an internal address
+
+            _logger.LogWarning("{Source}: External request received, however, no WAN interface found.", source);
+            return false;
+        }
+    }
+}

+ 3 - 0
Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs

@@ -17,6 +17,7 @@ using Jellyfin.Api.Auth.LocalAccessPolicy;
 using Jellyfin.Api.Auth.RequiresElevationPolicy;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Controllers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Server.Configuration;
 using Jellyfin.Server.Filters;
 using Jellyfin.Server.Formatters;
@@ -169,6 +170,8 @@ namespace Jellyfin.Server.Extensions
 
                     opts.OutputFormatters.Add(new CssOutputFormatter());
                     opts.OutputFormatters.Add(new XmlOutputFormatter());
+
+                    opts.ModelBinderProviders.Insert(0, new NullableEnumModelBinderProvider());
                 })
 
                 // Clear app parts to avoid other assemblies being picked up

+ 2 - 2
Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Model.Updates;
 
@@ -46,4 +46,4 @@ namespace Jellyfin.Server.Migrations.Routines
             }
         }
     }
-}
+}

+ 8 - 6
Jellyfin.Server/Startup.cs

@@ -1,8 +1,5 @@
-using System;
-using System.ComponentModel;
 using System.Net.Http.Headers;
 using System.Net.Mime;
-using Jellyfin.Api.TypeConverters;
 using Jellyfin.Server.Extensions;
 using Jellyfin.Server.Implementations;
 using Jellyfin.Server.Middleware;
@@ -66,10 +63,16 @@ namespace Jellyfin.Server
             var productHeader = new ProductInfoHeaderValue(
                 _serverApplicationHost.Name.Replace(' ', '-'),
                 _serverApplicationHost.ApplicationVersionString);
+            var acceptJsonHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json, 1.0);
+            var acceptXmlHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml, 0.9);
+            var acceptAnyHeader = new MediaTypeWithQualityHeaderValue("*/*", 0.8);
             services
                 .AddHttpClient(NamedClient.Default, c =>
                 {
                     c.DefaultRequestHeaders.UserAgent.Add(productHeader);
+                    c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader);
+                    c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
+                    c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
                 })
                 .ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
 
@@ -77,6 +80,8 @@ namespace Jellyfin.Server
                 {
                     c.DefaultRequestHeaders.UserAgent.Add(productHeader);
                     c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue($"({_serverApplicationHost.ApplicationUserAgentAddress})"));
+                    c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader);
+                    c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
                 })
                 .ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
 
@@ -164,9 +169,6 @@ namespace Jellyfin.Server
                     endpoints.MapHealthChecks("/health");
                 });
             });
-
-            // Add type descriptor for legacy datetime parsing.
-            TypeDescriptor.AddAttributes(typeof(DateTime?), new TypeConverterAttribute(typeof(DateTimeTypeConverter)));
         }
     }
 }

+ 2 - 1
MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs

@@ -43,7 +43,8 @@ namespace MediaBrowser.Common.Json.Converters
                     }
                     catch (FormatException)
                     {
-                        // TODO log when upgraded to .Net5
+                        // TODO log when upgraded to .Net6
+                        // https://github.com/dotnet/runtime/issues/42975
                         // _logger.LogWarning(e, "Error converting value.");
                     }
                 }

+ 24 - 0
MediaBrowser.Common/Json/Converters/JsonDateTimeIso8601Converter.cs

@@ -0,0 +1,24 @@
+using System;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+    /// <summary>
+    /// Returns an ISO8601 formatted datetime.
+    /// </summary>
+    /// <remarks>
+    /// Used for legacy compatibility.
+    /// </remarks>
+    public class JsonDateTimeIso8601Converter : JsonConverter<DateTime>
+    {
+        /// <inheritdoc />
+        public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+            => reader.GetDateTime();
+
+        /// <inheritdoc />
+        public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
+            => writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture));
+    }
+}

+ 75 - 0
MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs

@@ -0,0 +1,75 @@
+using System;
+using System.ComponentModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+    /// <summary>
+    /// Convert Pipe delimited string to array of type.
+    /// </summary>
+    /// <typeparam name="T">Type to convert to.</typeparam>
+    public class JsonPipeDelimitedArrayConverter<T> : JsonConverter<T[]>
+    {
+        private readonly TypeConverter _typeConverter;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="JsonPipeDelimitedArrayConverter{T}"/> class.
+        /// </summary>
+        public JsonPipeDelimitedArrayConverter()
+        {
+            _typeConverter = TypeDescriptor.GetConverter(typeof(T));
+        }
+
+        /// <inheritdoc />
+        public override T[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+        {
+            if (reader.TokenType == JsonTokenType.String)
+            {
+                var stringEntries = reader.GetString()?.Split('|', StringSplitOptions.RemoveEmptyEntries);
+                if (stringEntries == null || stringEntries.Length == 0)
+                {
+                    return Array.Empty<T>();
+                }
+
+                var parsedValues = new object[stringEntries.Length];
+                var convertedCount = 0;
+                for (var i = 0; i < stringEntries.Length; i++)
+                {
+                    try
+                    {
+                        parsedValues[i] = _typeConverter.ConvertFrom(stringEntries[i].Trim());
+                        convertedCount++;
+                    }
+                    catch (FormatException)
+                    {
+                        // TODO log when upgraded to .Net6
+                        // https://github.com/dotnet/runtime/issues/42975
+                        // _logger.LogWarning(e, "Error converting value.");
+                    }
+                }
+
+                var typedValues = new T[convertedCount];
+                var typedValueIndex = 0;
+                for (var i = 0; i < stringEntries.Length; i++)
+                {
+                    if (parsedValues[i] != null)
+                    {
+                        typedValues.SetValue(parsedValues[i], typedValueIndex);
+                        typedValueIndex++;
+                    }
+                }
+
+                return typedValues;
+            }
+
+            return JsonSerializer.Deserialize<T[]>(ref reader, options);
+        }
+
+        /// <inheritdoc />
+        public override void Write(Utf8JsonWriter writer, T[] value, JsonSerializerOptions options)
+        {
+            JsonSerializer.Serialize(writer, value, options);
+        }
+    }
+}

+ 28 - 0
MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs

@@ -0,0 +1,28 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+    /// <summary>
+    /// Json Pipe delimited array converter factory.
+    /// </summary>
+    /// <remarks>
+    /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow.
+    /// </remarks>
+    public class JsonPipeDelimitedArrayConverterFactory : JsonConverterFactory
+    {
+        /// <inheritdoc />
+        public override bool CanConvert(Type typeToConvert)
+        {
+            return true;
+        }
+
+        /// <inheritdoc />
+        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+        {
+            var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0];
+            return (JsonConverter)Activator.CreateInstance(typeof(JsonPipeDelimitedArrayConverter<>).MakeGenericType(structType));
+        }
+    }
+}

+ 1 - 0
MediaBrowser.Common/Json/JsonDefaults.cs

@@ -42,6 +42,7 @@ namespace MediaBrowser.Common.Json
             options.Converters.Add(new JsonGuidConverter());
             options.Converters.Add(new JsonStringEnumConverter());
             options.Converters.Add(new JsonNullableStructConverterFactory());
+            options.Converters.Add(new JsonDateTimeIso8601Converter());
 
             return options;
         }

+ 1 - 1
MediaBrowser.Common/MediaBrowser.Common.csproj

@@ -20,7 +20,7 @@
   <ItemGroup>
     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
-    <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
+    <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
     <PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" />
   </ItemGroup>
 

+ 445 - 0
MediaBrowser.Common/Net/IPHost.cs

@@ -0,0 +1,445 @@
+#nullable enable
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+
+namespace MediaBrowser.Common.Net
+{
+    /// <summary>
+    /// Object that holds a host name.
+    /// </summary>
+    public class IPHost : IPObject
+    {
+        /// <summary>
+        /// Gets or sets timeout value before resolve required, in minutes.
+        /// </summary>
+        public const int Timeout = 30;
+
+        /// <summary>
+        /// Represents an IPHost that has no value.
+        /// </summary>
+        public static readonly IPHost None = new IPHost(string.Empty, IPAddress.None);
+
+        /// <summary>
+        /// Time when last resolved in ticks.
+        /// </summary>
+        private DateTime? _lastResolved = null;
+
+        /// <summary>
+        /// Gets the IP Addresses, attempting to resolve the name, if there are none.
+        /// </summary>
+        private IPAddress[] _addresses;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="IPHost"/> class.
+        /// </summary>
+        /// <param name="name">Host name to assign.</param>
+        public IPHost(string name)
+        {
+            HostName = name ?? throw new ArgumentNullException(nameof(name));
+            _addresses = Array.Empty<IPAddress>();
+            Resolved = false;
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="IPHost"/> class.
+        /// </summary>
+        /// <param name="name">Host name to assign.</param>
+        /// <param name="address">Address to assign.</param>
+        private IPHost(string name, IPAddress address)
+        {
+            HostName = name ?? throw new ArgumentNullException(nameof(name));
+            _addresses = new IPAddress[] { address ?? throw new ArgumentNullException(nameof(address)) };
+            Resolved = !address.Equals(IPAddress.None);
+        }
+
+        /// <summary>
+        /// Gets or sets the object's first IP address.
+        /// </summary>
+        public override IPAddress Address
+        {
+            get
+            {
+                return ResolveHost() ? this[0] : IPAddress.None;
+            }
+
+            set
+            {
+                // Not implemented, as a host's address is determined by DNS.
+                throw new NotImplementedException("The address of a host is determined by DNS.");
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets the object's first IP's subnet prefix.
+        /// The setter does nothing, but shouldn't raise an exception.
+        /// </summary>
+        public override byte PrefixLength
+        {
+            get
+            {
+                return (byte)(ResolveHost() ? 128 : 32);
+            }
+
+            set
+            {
+                // Not implemented, as a host object can only have a prefix length of 128 (IPv6) or 32 (IPv4) prefix length,
+                // which is automatically determined by it's IP type. Anything else is meaningless.
+            }
+        }
+
+        /// <summary>
+        /// Gets a value indicating whether the address has a value.
+        /// </summary>
+        public bool HasAddress => _addresses.Length != 0;
+
+        /// <summary>
+        /// Gets the host name of this object.
+        /// </summary>
+        public string HostName { get; }
+
+        /// <summary>
+        /// Gets a value indicating whether this host has attempted to be resolved.
+        /// </summary>
+        public bool Resolved { get; private set; }
+
+        /// <summary>
+        /// Gets or sets the IP Addresses associated with this object.
+        /// </summary>
+        /// <param name="index">Index of address.</param>
+        public IPAddress this[int index]
+        {
+            get
+            {
+                ResolveHost();
+                return index >= 0 && index < _addresses.Length ? _addresses[index] : IPAddress.None;
+            }
+        }
+
+        /// <summary>
+        /// Attempts to parse the host string.
+        /// </summary>
+        /// <param name="host">Host name to parse.</param>
+        /// <param name="hostObj">Object representing the string, if it has successfully been parsed.</param>
+        /// <returns><c>true</c> if the parsing is successful, <c>false</c> if not.</returns>
+        public static bool TryParse(string host, out IPHost hostObj)
+        {
+            if (!string.IsNullOrEmpty(host))
+            {
+                // See if it's an IPv6 with port address e.g. [::1]:120.
+                int i = host.IndexOf("]:", StringComparison.OrdinalIgnoreCase);
+                if (i != -1)
+                {
+                    return TryParse(host.Remove(i - 1).TrimStart(' ', '['), out hostObj);
+                }
+                else
+                {
+                    // See if it's an IPv6 in [] with no port.
+                    i = host.IndexOf(']', StringComparison.OrdinalIgnoreCase);
+                    if (i != -1)
+                    {
+                        return TryParse(host.Remove(i - 1).TrimStart(' ', '['), out hostObj);
+                    }
+
+                    // Is it a host or IPv4 with port?
+                    string[] hosts = host.Split(':');
+
+                    if (hosts.Length > 2)
+                    {
+                        hostObj = new IPHost(string.Empty, IPAddress.None);
+                        return false;
+                    }
+
+                    // Remove port from IPv4 if it exists.
+                    host = hosts[0];
+
+                    if (string.Equals("localhost", host, StringComparison.OrdinalIgnoreCase))
+                    {
+                        hostObj = new IPHost(host, new IPAddress(Ipv4Loopback));
+                        return true;
+                    }
+
+                    if (IPNetAddress.TryParse(host, out IPNetAddress netIP))
+                    {
+                        // Host name is an ip address, so fake resolve.
+                        hostObj = new IPHost(host, netIP.Address);
+                        return true;
+                    }
+                }
+
+                // Only thing left is to see if it's a host string.
+                if (!string.IsNullOrEmpty(host))
+                {
+                    // Use regular expression as CheckHostName isn't RFC5892 compliant.
+                    // Modified from gSkinner's expression at https://stackoverflow.com/questions/11809631/fully-qualified-domain-name-validation
+                    Regex re = new Regex(@"^(?!:\/\/)(?=.{1,255}$)((.{1,63}\.){0,127}(?![0-9]*$)[a-z0-9-]+\.?)$", RegexOptions.IgnoreCase | RegexOptions.Multiline);
+                    if (re.Match(host).Success)
+                    {
+                        hostObj = new IPHost(host);
+                        return true;
+                    }
+                }
+            }
+
+            hostObj = IPHost.None;
+            return false;
+        }
+
+        /// <summary>
+        /// Attempts to parse the host string.
+        /// </summary>
+        /// <param name="host">Host name to parse.</param>
+        /// <returns>Object representing the string, if it has successfully been parsed.</returns>
+        public static IPHost Parse(string host)
+        {
+            if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res))
+            {
+                return res;
+            }
+
+            throw new InvalidCastException("Host does not contain a valid value. {host}");
+        }
+
+        /// <summary>
+        /// Attempts to parse the host string, ensuring that it resolves only to a specific IP type.
+        /// </summary>
+        /// <param name="host">Host name to parse.</param>
+        /// <param name="family">Addressfamily filter.</param>
+        /// <returns>Object representing the string, if it has successfully been parsed.</returns>
+        public static IPHost Parse(string host, AddressFamily family)
+        {
+            if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res))
+            {
+                if (family == AddressFamily.InterNetwork)
+                {
+                    res.Remove(AddressFamily.InterNetworkV6);
+                }
+                else
+                {
+                    res.Remove(AddressFamily.InterNetwork);
+                }
+
+                return res;
+            }
+
+            throw new InvalidCastException("Host does not contain a valid value. {host}");
+        }
+
+        /// <summary>
+        /// Returns the Addresses that this item resolved to.
+        /// </summary>
+        /// <returns>IPAddress Array.</returns>
+        public IPAddress[] GetAddresses()
+        {
+            ResolveHost();
+            return _addresses;
+        }
+
+        /// <inheritdoc/>
+        public override bool Contains(IPAddress address)
+        {
+            if (address != null && !Address.Equals(IPAddress.None))
+            {
+                if (address.IsIPv4MappedToIPv6)
+                {
+                    address = address.MapToIPv4();
+                }
+
+                foreach (var addr in GetAddresses())
+                {
+                    if (address.Equals(addr))
+                    {
+                        return true;
+                    }
+                }
+            }
+
+            return false;
+        }
+
+        /// <inheritdoc/>
+        public override bool Equals(IPObject? other)
+        {
+            if (other is IPHost otherObj)
+            {
+                // Do we have the name Hostname?
+                if (string.Equals(otherObj.HostName, HostName, StringComparison.OrdinalIgnoreCase))
+                {
+                    return true;
+                }
+
+                if (!ResolveHost() || !otherObj.ResolveHost())
+                {
+                    return false;
+                }
+
+                // Do any of our IP addresses match?
+                foreach (IPAddress addr in _addresses)
+                {
+                    foreach (IPAddress otherAddress in otherObj._addresses)
+                    {
+                        if (addr.Equals(otherAddress))
+                        {
+                            return true;
+                        }
+                    }
+                }
+            }
+
+            return false;
+        }
+
+        /// <inheritdoc/>
+        public override bool IsIP6()
+        {
+            // Returns true if interfaces are only IP6.
+            if (ResolveHost())
+            {
+                foreach (IPAddress i in _addresses)
+                {
+                    if (i.AddressFamily != AddressFamily.InterNetworkV6)
+                    {
+                        return false;
+                    }
+                }
+
+                return true;
+            }
+
+            return false;
+        }
+
+        /// <inheritdoc/>
+        public override string ToString()
+        {
+            // StringBuilder not optimum here.
+            string output = string.Empty;
+            if (_addresses.Length > 0)
+            {
+                bool moreThanOne = _addresses.Length > 1;
+                if (moreThanOne)
+                {
+                    output = "[";
+                }
+
+                foreach (var i in _addresses)
+                {
+                    if (Address.Equals(IPAddress.None) && Address.AddressFamily == AddressFamily.Unspecified)
+                    {
+                        output += HostName + ",";
+                    }
+                    else if (i.Equals(IPAddress.Any))
+                    {
+                        output += "Any IP4 Address,";
+                    }
+                    else if (Address.Equals(IPAddress.IPv6Any))
+                    {
+                        output += "Any IP6 Address,";
+                    }
+                    else if (i.Equals(IPAddress.Broadcast))
+                    {
+                        output += "Any Address,";
+                    }
+                    else
+                    {
+                        output += $"{i}/32,";
+                    }
+                }
+
+                output = output[0..^1];
+
+                if (moreThanOne)
+                {
+                    output += "]";
+                }
+            }
+            else
+            {
+                output = HostName;
+            }
+
+            return output;
+        }
+
+        /// <inheritdoc/>
+        public override void Remove(AddressFamily family)
+        {
+            if (ResolveHost())
+            {
+                _addresses = _addresses.Where(p => p.AddressFamily != family).ToArray();
+            }
+        }
+
+        /// <inheritdoc/>
+        public override bool Contains(IPObject address)
+        {
+            // An IPHost cannot contain another IPObject, it can only be equal.
+            return Equals(address);
+        }
+
+        /// <inheritdoc/>
+        protected override IPObject CalculateNetworkAddress()
+        {
+            var netAddr = NetworkAddressOf(this[0], PrefixLength);
+            return new IPNetAddress(netAddr.Address, netAddr.PrefixLength);
+        }
+
+        /// <summary>
+        /// Attempt to resolve the ip address of a host.
+        /// </summary>
+        /// <returns><c>true</c> if any addresses have been resolved, otherwise <c>false</c>.</returns>
+        private bool ResolveHost()
+        {
+            // When was the last time we resolved?
+            if (_lastResolved == null)
+            {
+                _lastResolved = DateTime.UtcNow;
+            }
+
+            // If we haven't resolved before, or our timer has run out...
+            if ((_addresses.Length == 0 && !Resolved) || (DateTime.UtcNow > _lastResolved?.AddMinutes(Timeout)))
+            {
+                _lastResolved = DateTime.UtcNow;
+                ResolveHostInternal().GetAwaiter().GetResult();
+                Resolved = true;
+            }
+
+            return _addresses.Length > 0;
+        }
+
+        /// <summary>
+        /// Task that looks up a Host name and returns its IP addresses.
+        /// </summary>
+        /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+        private async Task ResolveHostInternal()
+        {
+            if (!string.IsNullOrEmpty(HostName))
+            {
+                // Resolves the host name - so save a DNS lookup.
+                if (string.Equals(HostName, "localhost", StringComparison.OrdinalIgnoreCase))
+                {
+                    _addresses = new IPAddress[] { new IPAddress(Ipv4Loopback), new IPAddress(Ipv6Loopback) };
+                    return;
+                }
+
+                if (Uri.CheckHostName(HostName).Equals(UriHostNameType.Dns))
+                {
+                    try
+                    {
+                        IPHostEntry ip = await Dns.GetHostEntryAsync(HostName).ConfigureAwait(false);
+                        _addresses = ip.AddressList;
+                    }
+                    catch (SocketException ex)
+                    {
+                        // Log and then ignore socket errors, as the result value will just be an empty array.
+                        Debug.WriteLine("GetHostEntryAsync failed with {Message}.", ex.Message);
+                    }
+                }
+            }
+        }
+    }
+}

+ 277 - 0
MediaBrowser.Common/Net/IPNetAddress.cs

@@ -0,0 +1,277 @@
+#nullable enable
+using System;
+using System.Net;
+using System.Net.Sockets;
+
+namespace MediaBrowser.Common.Net
+{
+    /// <summary>
+    /// An object that holds and IP address and subnet mask.
+    /// </summary>
+    public class IPNetAddress : IPObject
+    {
+        /// <summary>
+        /// Represents an IPNetAddress that has no value.
+        /// </summary>
+        public static readonly IPNetAddress None = new IPNetAddress(IPAddress.None);
+
+        /// <summary>
+        /// IPv4 multicast address.
+        /// </summary>
+        public static readonly IPAddress SSDPMulticastIPv4 = IPAddress.Parse("239.255.255.250");
+
+        /// <summary>
+        /// IPv6 local link multicast address.
+        /// </summary>
+        public static readonly IPAddress SSDPMulticastIPv6LinkLocal = IPAddress.Parse("ff02::C");
+
+        /// <summary>
+        /// IPv6 site local multicast address.
+        /// </summary>
+        public static readonly IPAddress SSDPMulticastIPv6SiteLocal = IPAddress.Parse("ff05::C");
+
+        /// <summary>
+        /// IP4Loopback address host.
+        /// </summary>
+        public static readonly IPNetAddress IP4Loopback = IPNetAddress.Parse("127.0.0.1/32");
+
+        /// <summary>
+        /// IP6Loopback address host.
+        /// </summary>
+        public static readonly IPNetAddress IP6Loopback = IPNetAddress.Parse("::1");
+
+        /// <summary>
+        /// Object's IP address.
+        /// </summary>
+        private IPAddress _address;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="IPNetAddress"/> class.
+        /// </summary>
+        /// <param name="address">Address to assign.</param>
+        public IPNetAddress(IPAddress address)
+        {
+            _address = address ?? throw new ArgumentNullException(nameof(address));
+            PrefixLength = (byte)(address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128);
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="IPNetAddress"/> class.
+        /// </summary>
+        /// <param name="address">IP Address.</param>
+        /// <param name="prefixLength">Mask as a CIDR.</param>
+        public IPNetAddress(IPAddress address, byte prefixLength)
+        {
+            if (address?.IsIPv4MappedToIPv6 ?? throw new ArgumentNullException(nameof(address)))
+            {
+                _address = address.MapToIPv4();
+            }
+            else
+            {
+                _address = address;
+            }
+
+            PrefixLength = prefixLength;
+        }
+
+        /// <summary>
+        /// Gets or sets the object's IP address.
+        /// </summary>
+        public override IPAddress Address
+        {
+            get
+            {
+                return _address;
+            }
+
+            set
+            {
+                _address = value ?? IPAddress.None;
+            }
+        }
+
+        /// <inheritdoc/>
+        public override byte PrefixLength { get; set; }
+
+        /// <summary>
+        /// Try to parse the address and subnet strings into an IPNetAddress object.
+        /// </summary>
+        /// <param name="addr">IP address to parse. Can be CIDR or X.X.X.X notation.</param>
+        /// <param name="ip">Resultant object.</param>
+        /// <returns>True if the values parsed successfully. False if not, resulting in the IP being null.</returns>
+        public static bool TryParse(string addr, out IPNetAddress ip)
+        {
+            if (!string.IsNullOrEmpty(addr))
+            {
+                addr = addr.Trim();
+
+                // Try to parse it as is.
+                if (IPAddress.TryParse(addr, out IPAddress? res))
+                {
+                    ip = new IPNetAddress(res);
+                    return true;
+                }
+
+                // Is it a network?
+                string[] tokens = addr.Split("/");
+
+                if (tokens.Length == 2)
+                {
+                    tokens[0] = tokens[0].TrimEnd();
+                    tokens[1] = tokens[1].TrimStart();
+
+                    if (IPAddress.TryParse(tokens[0], out res))
+                    {
+                        // Is the subnet part a cidr?
+                        if (byte.TryParse(tokens[1], out byte cidr))
+                        {
+                            ip = new IPNetAddress(res, cidr);
+                            return true;
+                        }
+
+                        // Is the subnet in x.y.a.b form?
+                        if (IPAddress.TryParse(tokens[1], out IPAddress? mask))
+                        {
+                            ip = new IPNetAddress(res, MaskToCidr(mask));
+                            return true;
+                        }
+                    }
+                }
+            }
+
+            ip = None;
+            return false;
+        }
+
+        /// <summary>
+        /// Parses the string provided, throwing an exception if it is badly formed.
+        /// </summary>
+        /// <param name="addr">String to parse.</param>
+        /// <returns>IPNetAddress object.</returns>
+        public static IPNetAddress Parse(string addr)
+        {
+            if (TryParse(addr, out IPNetAddress o))
+            {
+                return o;
+            }
+
+            throw new ArgumentException("Unable to recognise object :" + addr);
+        }
+
+        /// <inheritdoc/>
+        public override bool Contains(IPAddress address)
+        {
+            if (address == null)
+            {
+                throw new ArgumentNullException(nameof(address));
+            }
+
+            if (address.IsIPv4MappedToIPv6)
+            {
+                address = address.MapToIPv4();
+            }
+
+            var altAddress = NetworkAddressOf(address, PrefixLength);
+            return NetworkAddress.Address.Equals(altAddress.Address) && NetworkAddress.PrefixLength >= altAddress.PrefixLength;
+        }
+
+        /// <inheritdoc/>
+        public override bool Contains(IPObject address)
+        {
+            if (address is IPHost addressObj && addressObj.HasAddress)
+            {
+                foreach (IPAddress addr in addressObj.GetAddresses())
+                {
+                    if (Contains(addr))
+                    {
+                        return true;
+                    }
+                }
+            }
+            else if (address is IPNetAddress netaddrObj)
+            {
+                // Have the same network address, but different subnets?
+                if (NetworkAddress.Address.Equals(netaddrObj.NetworkAddress.Address))
+                {
+                    return NetworkAddress.PrefixLength <= netaddrObj.PrefixLength;
+                }
+
+                var altAddress = NetworkAddressOf(netaddrObj.Address, PrefixLength);
+                return NetworkAddress.Address.Equals(altAddress.Address);
+            }
+
+            return false;
+        }
+
+        /// <inheritdoc/>
+        public override bool Equals(IPObject? other)
+        {
+            if (other is IPNetAddress otherObj && !Address.Equals(IPAddress.None) && !otherObj.Address.Equals(IPAddress.None))
+            {
+                return Address.Equals(otherObj.Address) &&
+                    PrefixLength == otherObj.PrefixLength;
+            }
+
+            return false;
+        }
+
+        /// <inheritdoc/>
+        public override bool Equals(IPAddress address)
+        {
+            if (address != null && !address.Equals(IPAddress.None) && !Address.Equals(IPAddress.None))
+            {
+                return address.Equals(Address);
+            }
+
+            return false;
+        }
+
+        /// <inheritdoc/>
+        public override string ToString()
+        {
+            return ToString(false);
+        }
+
+        /// <summary>
+        /// Returns a textual representation of this object.
+        /// </summary>
+        /// <param name="shortVersion">Set to true, if the subnet is to be excluded as part of the address.</param>
+        /// <returns>String representation of this object.</returns>
+        public string ToString(bool shortVersion)
+        {
+            if (!Address.Equals(IPAddress.None))
+            {
+                if (Address.Equals(IPAddress.Any))
+                {
+                    return "Any IP4 Address";
+                }
+
+                if (Address.Equals(IPAddress.IPv6Any))
+                {
+                    return "Any IP6 Address";
+                }
+
+                if (Address.Equals(IPAddress.Broadcast))
+                {
+                    return "Any Address";
+                }
+
+                if (shortVersion)
+                {
+                    return Address.ToString();
+                }
+
+                return $"{Address}/{PrefixLength}";
+            }
+
+            return string.Empty;
+        }
+
+        /// <inheritdoc/>
+        protected override IPObject CalculateNetworkAddress()
+        {
+            var value = NetworkAddressOf(_address, PrefixLength);
+            return new IPNetAddress(value.Address, value.PrefixLength);
+        }
+    }
+}

+ 406 - 0
MediaBrowser.Common/Net/IPObject.cs

@@ -0,0 +1,406 @@
+#nullable enable
+using System;
+using System.Net;
+using System.Net.Sockets;
+
+namespace MediaBrowser.Common.Net
+{
+    /// <summary>
+    /// Base network object class.
+    /// </summary>
+    public abstract class IPObject : IEquatable<IPObject>
+    {
+        /// <summary>
+        /// IPv6 Loopback address.
+        /// </summary>
+        protected static readonly byte[] Ipv6Loopback = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
+
+        /// <summary>
+        /// IPv4 Loopback address.
+        /// </summary>
+        protected static readonly byte[] Ipv4Loopback = { 127, 0, 0, 1 };
+
+        /// <summary>
+        /// The network address of this object.
+        /// </summary>
+        private IPObject? _networkAddress;
+
+        /// <summary>
+        /// Gets or sets a user defined value that is associated with this object.
+        /// </summary>
+        public int Tag { get; set; }
+
+        /// <summary>
+        /// Gets or sets the object's IP address.
+        /// </summary>
+        public abstract IPAddress Address { get; set; }
+
+        /// <summary>
+        /// Gets the object's network address.
+        /// </summary>
+        public IPObject NetworkAddress => _networkAddress ??= CalculateNetworkAddress();
+
+        /// <summary>
+        /// Gets or sets the object's IP address.
+        /// </summary>
+        public abstract byte PrefixLength { get; set; }
+
+        /// <summary>
+        /// Gets the AddressFamily of this object.
+        /// </summary>
+        public AddressFamily AddressFamily
+        {
+            get
+            {
+                // Keep terms separate as Address performs other functions in inherited objects.
+                IPAddress address = Address;
+                return address.Equals(IPAddress.None) ? AddressFamily.Unspecified : address.AddressFamily;
+            }
+        }
+
+        /// <summary>
+        /// Returns the network address of an object.
+        /// </summary>
+        /// <param name="address">IP Address to convert.</param>
+        /// <param name="prefixLength">Subnet prefix.</param>
+        /// <returns>IPAddress.</returns>
+        public static (IPAddress Address, byte PrefixLength) NetworkAddressOf(IPAddress address, byte prefixLength)
+        {
+            if (address == null)
+            {
+                throw new ArgumentNullException(nameof(address));
+            }
+
+            if (address.IsIPv4MappedToIPv6)
+            {
+                address = address.MapToIPv4();
+            }
+
+            if (IsLoopback(address))
+            {
+                return (Address: address, PrefixLength: prefixLength);
+            }
+
+            // An ip address is just a list of bytes, each one representing a segment on the network.
+            // This separates the IP address into octets and calculates how many octets will need to be altered or set to zero dependant upon the
+            // prefix length value. eg. /16 on a 4 octet ip4 address (192.168.2.240) will result in the 2 and the 240 being zeroed out.
+            // Where there is not an exact boundary (eg /23), mod is used to calculate how many bits of this value are to be kept.
+
+            // GetAddressBytes
+            Span<byte> addressBytes = stackalloc byte[address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
+            address.TryWriteBytes(addressBytes, out _);
+
+            int div = prefixLength / 8;
+            int mod = prefixLength % 8;
+            if (mod != 0)
+            {
+                // Prefix length is counted right to left, so subtract 8 so we know how many bits to clear.
+                mod = 8 - mod;
+
+                // Shift out the bits from the octet that we don't want, by moving right then back left.
+                addressBytes[div] = (byte)((int)addressBytes[div] >> mod << mod);
+                // Move on the next byte.
+                div++;
+            }
+
+            // Blank out the remaining octets from mod + 1 to the end of the byte array. (192.168.2.240/16 becomes 192.168.0.0)
+            for (int octet = div; octet < addressBytes.Length; octet++)
+            {
+                addressBytes[octet] = 0;
+            }
+
+            // Return the network address for the prefix.
+            return (Address: new IPAddress(addressBytes), PrefixLength: prefixLength);
+        }
+
+        /// <summary>
+        /// Tests to see if the ip address is a Loopback address.
+        /// </summary>
+        /// <param name="address">Value to test.</param>
+        /// <returns>True if it is.</returns>
+        public static bool IsLoopback(IPAddress address)
+        {
+            if (address == null)
+            {
+                throw new ArgumentNullException(nameof(address));
+            }
+
+            if (!address.Equals(IPAddress.None))
+            {
+                if (address.IsIPv4MappedToIPv6)
+                {
+                    address = address.MapToIPv4();
+                }
+
+                return address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback);
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Tests to see if the ip address is an IP6 address.
+        /// </summary>
+        /// <param name="address">Value to test.</param>
+        /// <returns>True if it is.</returns>
+        public static bool IsIP6(IPAddress address)
+        {
+            if (address == null)
+            {
+                throw new ArgumentNullException(nameof(address));
+            }
+
+            if (address.IsIPv4MappedToIPv6)
+            {
+                address = address.MapToIPv4();
+            }
+
+            return !address.Equals(IPAddress.None) && (address.AddressFamily == AddressFamily.InterNetworkV6);
+        }
+
+        /// <summary>
+        /// Tests to see if the address in the private address range.
+        /// </summary>
+        /// <param name="address">Object to test.</param>
+        /// <returns>True if it contains a private address.</returns>
+        public static bool IsPrivateAddressRange(IPAddress address)
+        {
+            if (address == null)
+            {
+                throw new ArgumentNullException(nameof(address));
+            }
+
+            if (!address.Equals(IPAddress.None))
+            {
+                if (address.IsIPv4MappedToIPv6)
+                {
+                    address = address.MapToIPv4();
+                }
+
+                if (address.AddressFamily == AddressFamily.InterNetwork)
+                {
+                    // GetAddressBytes
+                    Span<byte> octet = stackalloc byte[4];
+                    address.TryWriteBytes(octet, out _);
+
+                    return (octet[0] == 10)
+                           || (octet[0] == 172 && octet[1] >= 16 && octet[1] <= 31) // RFC1918
+                           || (octet[0] == 192 && octet[1] == 168) // RFC1918
+                           || (octet[0] == 127); // RFC1122
+                }
+                else
+                {
+                    // GetAddressBytes
+                    Span<byte> octet = stackalloc byte[16];
+                    address.TryWriteBytes(octet, out _);
+
+                    uint word = (uint)(octet[0] << 8) + octet[1];
+
+                    return (word >= 0xfe80 && word <= 0xfebf) // fe80::/10 :Local link.
+                           || (word >= 0xfc00 && word <= 0xfdff); // fc00::/7 :Unique local address.
+                }
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Returns true if the IPAddress contains an IP6 Local link address.
+        /// </summary>
+        /// <param name="address">IPAddress object to check.</param>
+        /// <returns>True if it is a local link address.</returns>
+        /// <remarks>
+        /// See https://stackoverflow.com/questions/6459928/explain-the-instance-properties-of-system-net-ipaddress
+        /// it appears that the IPAddress.IsIPv6LinkLocal is out of date.
+        /// </remarks>
+        public static bool IsIPv6LinkLocal(IPAddress address)
+        {
+            if (address == null)
+            {
+                throw new ArgumentNullException(nameof(address));
+            }
+
+            if (address.IsIPv4MappedToIPv6)
+            {
+                address = address.MapToIPv4();
+            }
+
+            if (address.AddressFamily != AddressFamily.InterNetworkV6)
+            {
+                return false;
+            }
+
+            // GetAddressBytes
+            Span<byte> octet = stackalloc byte[16];
+            address.TryWriteBytes(octet, out _);
+            uint word = (uint)(octet[0] << 8) + octet[1];
+
+            return word >= 0xfe80 && word <= 0xfebf; // fe80::/10 :Local link.
+        }
+
+        /// <summary>
+        /// Convert a subnet mask in CIDR notation to a dotted decimal string value. IPv4 only.
+        /// </summary>
+        /// <param name="cidr">Subnet mask in CIDR notation.</param>
+        /// <param name="family">IPv4 or IPv6 family.</param>
+        /// <returns>String value of the subnet mask in dotted decimal notation.</returns>
+        public static IPAddress CidrToMask(byte cidr, AddressFamily family)
+        {
+            uint addr = 0xFFFFFFFF << (family == AddressFamily.InterNetwork ? 32 : 128 - cidr);
+            addr = ((addr & 0xff000000) >> 24)
+                   | ((addr & 0x00ff0000) >> 8)
+                   | ((addr & 0x0000ff00) << 8)
+                   | ((addr & 0x000000ff) << 24);
+            return new IPAddress(addr);
+        }
+
+        /// <summary>
+        /// Convert a mask to a CIDR. IPv4 only.
+        /// https://stackoverflow.com/questions/36954345/get-cidr-from-netmask.
+        /// </summary>
+        /// <param name="mask">Subnet mask.</param>
+        /// <returns>Byte CIDR representing the mask.</returns>
+        public static byte MaskToCidr(IPAddress mask)
+        {
+            if (mask == null)
+            {
+                throw new ArgumentNullException(nameof(mask));
+            }
+
+            byte cidrnet = 0;
+            if (!mask.Equals(IPAddress.Any))
+            {
+                // GetAddressBytes
+                Span<byte> bytes = stackalloc byte[mask.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
+                mask.TryWriteBytes(bytes, out _);
+
+                var zeroed = false;
+                for (var i = 0; i < bytes.Length; i++)
+                {
+                    for (int v = bytes[i]; (v & 0xFF) != 0; v <<= 1)
+                    {
+                        if (zeroed)
+                        {
+                            // Invalid netmask.
+                            return (byte)~cidrnet;
+                        }
+
+                        if ((v & 0x80) == 0)
+                        {
+                            zeroed = true;
+                        }
+                        else
+                        {
+                            cidrnet++;
+                        }
+                    }
+                }
+            }
+
+            return cidrnet;
+        }
+
+        /// <summary>
+        /// Tests to see if this object is a Loopback address.
+        /// </summary>
+        /// <returns>True if it is.</returns>
+        public virtual bool IsLoopback()
+        {
+            return IsLoopback(Address);
+        }
+
+        /// <summary>
+        /// Removes all addresses of a specific type from this object.
+        /// </summary>
+        /// <param name="family">Type of address to remove.</param>
+        public virtual void Remove(AddressFamily family)
+        {
+            // This method only performs a function in the IPHost implementation of IPObject.
+        }
+
+        /// <summary>
+        /// Tests to see if this object is an IPv6 address.
+        /// </summary>
+        /// <returns>True if it is.</returns>
+        public virtual bool IsIP6()
+        {
+            return IsIP6(Address);
+        }
+
+        /// <summary>
+        /// Returns true if this IP address is in the RFC private address range.
+        /// </summary>
+        /// <returns>True this object has a private address.</returns>
+        public virtual bool IsPrivateAddressRange()
+        {
+            return IsPrivateAddressRange(Address);
+        }
+
+        /// <summary>
+        /// Compares this to the object passed as a parameter.
+        /// </summary>
+        /// <param name="ip">Object to compare to.</param>
+        /// <returns>Equality result.</returns>
+        public virtual bool Equals(IPAddress ip)
+        {
+            if (ip != null)
+            {
+                if (ip.IsIPv4MappedToIPv6)
+                {
+                    ip = ip.MapToIPv4();
+                }
+
+                return !Address.Equals(IPAddress.None) && Address.Equals(ip);
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Compares this to the object passed as a parameter.
+        /// </summary>
+        /// <param name="other">Object to compare to.</param>
+        /// <returns>Equality result.</returns>
+        public virtual bool Equals(IPObject? other)
+        {
+            if (other != null)
+            {
+                return !Address.Equals(IPAddress.None) && Address.Equals(other.Address);
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Compares the address in this object and the address in the object passed as a parameter.
+        /// </summary>
+        /// <param name="address">Object's IP address to compare to.</param>
+        /// <returns>Comparison result.</returns>
+        public abstract bool Contains(IPObject address);
+
+        /// <summary>
+        /// Compares the address in this object and the address in the object passed as a parameter.
+        /// </summary>
+        /// <param name="address">Object's IP address to compare to.</param>
+        /// <returns>Comparison result.</returns>
+        public abstract bool Contains(IPAddress address);
+
+        /// <inheritdoc/>
+        public override int GetHashCode()
+        {
+            return Address.GetHashCode();
+        }
+
+        /// <inheritdoc/>
+        public override bool Equals(object? obj)
+        {
+            return Equals(obj as IPObject);
+        }
+
+        /// <summary>
+        /// Calculates the network address of this object.
+        /// </summary>
+        /// <returns>Returns the network address of this object.</returns>
+        protected abstract IPObject CalculateNetworkAddress();
+    }
+}

+ 262 - 0
MediaBrowser.Common/Net/NetworkExtensions.cs

@@ -0,0 +1,262 @@
+#pragma warning disable CA1062 // Validate arguments of public methods
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Net;
+using System.Runtime.CompilerServices;
+using System.Text;
+
+namespace MediaBrowser.Common.Net
+{
+    /// <summary>
+    /// Defines the <see cref="NetworkExtensions" />.
+    /// </summary>
+    public static class NetworkExtensions
+    {
+        /// <summary>
+        /// Add an address to the collection.
+        /// </summary>
+        /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+        /// <param name="ip">Item to add.</param>
+        public static void AddItem(this Collection<IPObject> source, IPAddress ip)
+        {
+            if (!source.ContainsAddress(ip))
+            {
+                source.Add(new IPNetAddress(ip, 32));
+            }
+        }
+
+        /// <summary>
+        /// Adds a network to the collection.
+        /// </summary>
+        /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+        /// <param name="item">Item to add.</param>
+        public static void AddItem(this Collection<IPObject> source, IPObject item)
+        {
+            if (!source.ContainsAddress(item))
+            {
+                source.Add(item);
+            }
+        }
+
+        /// <summary>
+        /// Converts this object to a string.
+        /// </summary>
+        /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+        /// <returns>Returns a string representation of this object.</returns>
+        public static string AsString(this Collection<IPObject> source)
+        {
+            return $"[{string.Join(',', source)}]";
+        }
+
+        /// <summary>
+        /// Returns true if the collection contains an item with the ip address,
+        /// or the ip address falls within any of the collection's network ranges.
+        /// </summary>
+        /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+        /// <param name="item">The item to look for.</param>
+        /// <returns>True if the collection contains the item.</returns>
+        public static bool ContainsAddress(this Collection<IPObject> source, IPAddress item)
+        {
+            if (source.Count == 0)
+            {
+                return false;
+            }
+
+            if (item == null)
+            {
+                throw new ArgumentNullException(nameof(item));
+            }
+
+            if (item.IsIPv4MappedToIPv6)
+            {
+                item = item.MapToIPv4();
+            }
+
+            foreach (var i in source)
+            {
+                if (i.Contains(item))
+                {
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Returns true if the collection contains an item with the ip address,
+        /// or the ip address falls within any of the collection's network ranges.
+        /// </summary>
+        /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+        /// <param name="item">The item to look for.</param>
+        /// <returns>True if the collection contains the item.</returns>
+        public static bool ContainsAddress(this Collection<IPObject> source, IPObject item)
+        {
+            if (source.Count == 0)
+            {
+                return false;
+            }
+
+            if (item == null)
+            {
+                throw new ArgumentNullException(nameof(item));
+            }
+
+            foreach (var i in source)
+            {
+                if (i.Contains(item))
+                {
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Compares two Collection{IPObject} objects. The order is ignored.
+        /// </summary>
+        /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+        /// <param name="dest">Item to compare to.</param>
+        /// <returns>True if both are equal.</returns>
+        public static bool Compare(this Collection<IPObject> source, Collection<IPObject> dest)
+        {
+            if (dest == null || source.Count != dest.Count)
+            {
+                return false;
+            }
+
+            foreach (var sourceItem in source)
+            {
+                bool found = false;
+                foreach (var destItem in dest)
+                {
+                    if (sourceItem.Equals(destItem))
+                    {
+                        found = true;
+                        break;
+                    }
+                }
+
+                if (!found)
+                {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+
+        /// <summary>
+        /// Returns a collection containing the subnets of this collection given.
+        /// </summary>
+        /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+        /// <returns>Collection{IPObject} object containing the subnets.</returns>
+        public static Collection<IPObject> AsNetworks(this Collection<IPObject> source)
+        {
+            if (source == null)
+            {
+                throw new ArgumentNullException(nameof(source));
+            }
+
+            Collection<IPObject> res = new Collection<IPObject>();
+
+            foreach (IPObject i in source)
+            {
+                if (i is IPNetAddress nw)
+                {
+                    // Add the subnet calculated from the interface address/mask.
+                    var na = nw.NetworkAddress;
+                    na.Tag = i.Tag;
+                    res.AddItem(na);
+                }
+                else if (i is IPHost ipHost)
+                {
+                    // Flatten out IPHost and add all its ip addresses.
+                    foreach (var addr in ipHost.GetAddresses())
+                    {
+                        IPNetAddress host = new IPNetAddress(addr)
+                        {
+                            Tag = i.Tag
+                        };
+
+                        res.AddItem(host);
+                    }
+                }
+            }
+
+            return res;
+        }
+
+        /// <summary>
+        /// Excludes all the items from this list that are found in excludeList.
+        /// </summary>
+        /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+        /// <param name="excludeList">Items to exclude.</param>
+        /// <returns>A new collection, with the items excluded.</returns>
+        public static Collection<IPObject> Exclude(this Collection<IPObject> source, Collection<IPObject> excludeList)
+        {
+            if (source.Count == 0 || excludeList == null)
+            {
+                return new Collection<IPObject>(source);
+            }
+
+            Collection<IPObject> results = new Collection<IPObject>();
+
+            bool found;
+            foreach (var outer in source)
+            {
+                found = false;
+
+                foreach (var inner in excludeList)
+                {
+                    if (outer.Equals(inner))
+                    {
+                        found = true;
+                        break;
+                    }
+                }
+
+                if (!found)
+                {
+                    results.AddItem(outer);
+                }
+            }
+
+            return results;
+        }
+
+        /// <summary>
+        /// Returns all items that co-exist in this object and target.
+        /// </summary>
+        /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+        /// <param name="target">Collection to compare with.</param>
+        /// <returns>A collection containing all the matches.</returns>
+        public static Collection<IPObject> Union(this Collection<IPObject> source, Collection<IPObject> target)
+        {
+            if (source.Count == 0)
+            {
+                return new Collection<IPObject>();
+            }
+
+            if (target == null)
+            {
+                throw new ArgumentNullException(nameof(target));
+            }
+
+            Collection<IPObject> nc = new Collection<IPObject>();
+
+            foreach (IPObject i in source)
+            {
+                if (target.ContainsAddress(i))
+                {
+                    nc.AddItem(i);
+                }
+            }
+
+            return nc;
+        }
+    }
+}

+ 15 - 4
MediaBrowser.Common/Plugins/BasePlugin.cs

@@ -247,23 +247,34 @@ namespace MediaBrowser.Common.Plugins
             }
             catch
             {
-                return (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType));
+                var config = (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType));
+                SaveConfiguration(config);
+                return config;
             }
         }
 
         /// <summary>
         /// Saves the current configuration to the file system.
         /// </summary>
-        public virtual void SaveConfiguration()
+        /// <param name="config">Configuration to save.</param>
+        public virtual void SaveConfiguration(TConfigurationType config)
         {
             lock (_configurationSaveLock)
             {
                 _directoryCreateFn(Path.GetDirectoryName(ConfigurationFilePath));
 
-                XmlSerializer.SerializeToFile(Configuration, ConfigurationFilePath);
+                XmlSerializer.SerializeToFile(config, ConfigurationFilePath);
             }
         }
 
+        /// <summary>
+        /// Saves the current configuration to the file system.
+        /// </summary>
+        public virtual void SaveConfiguration()
+        {
+            SaveConfiguration(Configuration);
+        }
+
         /// <inheritdoc />
         public virtual void UpdateConfiguration(BasePluginConfiguration configuration)
         {
@@ -274,7 +285,7 @@ namespace MediaBrowser.Common.Plugins
 
             Configuration = (TConfigurationType)configuration;
 
-            SaveConfiguration();
+            SaveConfiguration(Configuration);
 
             ConfigurationChanged?.Invoke(this, configuration);
         }

+ 5 - 2
MediaBrowser.Common/Updates/IInstallationManager.cs

@@ -19,10 +19,11 @@ namespace MediaBrowser.Common.Updates
         /// <summary>
         /// Parses a plugin manifest at the supplied URL.
         /// </summary>
+        /// <param name="manifestName">Name of the repository.</param>
         /// <param name="manifest">The URL to query.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>Task{IReadOnlyList{PackageInfo}}.</returns>
-        Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default);
+        Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default);
 
         /// <summary>
         /// Gets all available packages.
@@ -37,11 +38,13 @@ namespace MediaBrowser.Common.Updates
         /// <param name="availablePackages">The available packages.</param>
         /// <param name="name">The name of the plugin.</param>
         /// <param name="guid">The id of the plugin.</param>
+        /// <param name="specificVersion">The version of the plugin.</param>
         /// <returns>All plugins matching the requirements.</returns>
         IEnumerable<PackageInfo> FilterPackages(
             IEnumerable<PackageInfo> availablePackages,
             string name = null,
-            Guid guid = default);
+            Guid guid = default,
+            Version specificVersion = null);
 
         /// <summary>
         /// Returns all compatible versions ordered from newest to oldest.

+ 3 - 3
MediaBrowser.Controller/Entities/Folder.cs

@@ -1099,12 +1099,12 @@ namespace MediaBrowser.Controller.Entities
                 return false;
             }
 
-            if (request.Genres.Length > 0)
+            if (request.Genres.Count > 0)
             {
                 return false;
             }
 
-            if (request.GenreIds.Length > 0)
+            if (request.GenreIds.Count > 0)
             {
                 return false;
             }
@@ -1209,7 +1209,7 @@ namespace MediaBrowser.Controller.Entities
                 return false;
             }
 
-            if (request.GenreIds.Length > 0)
+            if (request.GenreIds.Count > 0)
             {
                 return false;
             }

+ 3 - 3
MediaBrowser.Controller/Entities/InternalItemsQuery.cs

@@ -46,7 +46,7 @@ namespace MediaBrowser.Controller.Entities
 
         public string[] ExcludeInheritedTags { get; set; }
 
-        public string[] Genres { get; set; }
+        public IReadOnlyList<string> Genres { get; set; }
 
         public bool? IsSpecialSeason { get; set; }
 
@@ -116,7 +116,7 @@ namespace MediaBrowser.Controller.Entities
 
         public Guid[] StudioIds { get; set; }
 
-        public Guid[] GenreIds { get; set; }
+        public IReadOnlyList<Guid> GenreIds { get; set; }
 
         public ImageType[] ImageTypes { get; set; }
 
@@ -162,7 +162,7 @@ namespace MediaBrowser.Controller.Entities
 
         public double? MinCommunityRating { get; set; }
 
-        public Guid[] ChannelIds { get; set; }
+        public IReadOnlyList<Guid> ChannelIds { get; set; }
 
         public int? ParentIndexNumber { get; set; }
 

+ 2 - 2
MediaBrowser.Controller/Entities/UserViewBuilder.cs

@@ -791,7 +791,7 @@ namespace MediaBrowser.Controller.Entities
             }
 
             // Apply genre filter
-            if (query.Genres.Length > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase)))
+            if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase)))
             {
                 return false;
             }
@@ -822,7 +822,7 @@ namespace MediaBrowser.Controller.Entities
             }
 
             // Apply genre filter
-            if (query.GenreIds.Length > 0 && !query.GenreIds.Any(id =>
+            if (query.GenreIds.Count > 0 && !query.GenreIds.Any(id =>
             {
                 var genreItem = libraryManager.GetItemById(id);
                 return genreItem != null && item.Genres.Contains(genreItem.Name, StringComparer.OrdinalIgnoreCase);

+ 378 - 147
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -7,6 +7,7 @@ using System.IO;
 using System.Linq;
 using System.Runtime.InteropServices;
 using System.Text;
+using System.Text.RegularExpressions;
 using System.Threading;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Entities;
@@ -23,7 +24,7 @@ namespace MediaBrowser.Controller.MediaEncoding
 {
     public class EncodingHelper
     {
-        private readonly CultureInfo _usCulture = new CultureInfo("en-US");
+        private static readonly CultureInfo _usCulture = new CultureInfo("en-US");
 
         private readonly IMediaEncoder _mediaEncoder;
         private readonly IFileSystem _fileSystem;
@@ -440,6 +441,12 @@ namespace MediaBrowser.Controller.MediaEncoding
                 return "libopus";
             }
 
+            if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase))
+            {
+                // flac is experimental in mp4 muxer
+                return "flac -strict -2";
+            }
+
             return codec.ToLowerInvariant();
         }
 
@@ -573,7 +580,7 @@ namespace MediaBrowser.Controller.MediaEncoding
         /// </summary>
         /// <param name="stream">The stream.</param>
         /// <returns><c>true</c> if the specified stream is H264; otherwise, <c>false</c>.</returns>
-        public bool IsH264(MediaStream stream)
+        public static bool IsH264(MediaStream stream)
         {
             var codec = stream.Codec ?? string.Empty;
 
@@ -581,7 +588,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                     || codec.IndexOf("avc", StringComparison.OrdinalIgnoreCase) != -1;
         }
 
-        public bool IsH265(MediaStream stream)
+        public static bool IsH265(MediaStream stream)
         {
             var codec = stream.Codec ?? string.Empty;
 
@@ -589,10 +596,17 @@ namespace MediaBrowser.Controller.MediaEncoding
                 || codec.IndexOf("hevc", StringComparison.OrdinalIgnoreCase) != -1;
         }
 
-        // TODO This is auto inserted into the mpegts mux so it might not be needed
-        // https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb
-        public string GetBitStreamArgs(MediaStream stream)
+        public static bool IsAAC(MediaStream stream)
         {
+            var codec = stream.Codec ?? string.Empty;
+
+            return codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1;
+        }
+
+        public static string GetBitStreamArgs(MediaStream stream)
+        {
+            // TODO This is auto inserted into the mpegts mux so it might not be needed.
+            // https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb
             if (IsH264(stream))
             {
                 return "-bsf:v h264_mp4toannexb";
@@ -601,12 +615,44 @@ namespace MediaBrowser.Controller.MediaEncoding
             {
                 return "-bsf:v hevc_mp4toannexb";
             }
+            else if (IsAAC(stream))
+            {
+                // Convert adts header(mpegts) to asc header(mp4).
+                return "-bsf:a aac_adtstoasc";
+            }
             else
             {
                 return null;
             }
         }
 
+        public static string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer)
+        {
+            var bitStreamArgs = string.Empty;
+            var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.');
+
+            // Apply aac_adtstoasc bitstream filter when media source is in mpegts.
+            if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)
+                && (string.Equals(mediaSourceContainer, "mpegts", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase)))
+            {
+                bitStreamArgs = GetBitStreamArgs(state.AudioStream);
+                bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs;
+            }
+
+            return bitStreamArgs;
+        }
+
+        public static string GetSegmentFileExtension(string segmentContainer)
+        {
+            if (!string.IsNullOrWhiteSpace(segmentContainer))
+            {
+                return "." + segmentContainer;
+            }
+
+            return ".ts";
+        }
+
         public string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec)
         {
             var bitrate = state.OutputVideoBitrate;
@@ -654,16 +700,30 @@ namespace MediaBrowser.Controller.MediaEncoding
             return string.Empty;
         }
 
-        public string NormalizeTranscodingLevel(string videoCodec, string level)
+        public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
         {
-            // Clients may direct play higher than level 41, but there's no reason to transcode higher
-            if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel)
-                && requestLevel > 41
-                && (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase)))
+            if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel))
             {
-                return "41";
+                if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase))
+                {
+                    // Transcode to level 5.0 and lower for maximum compatibility.
+                    // Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it.
+                    // https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels
+                    // MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880.
+                    if (requestLevel >= 150)
+                    {
+                        return "150";
+                    }
+                }
+                else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
+                {
+                    // Clients may direct play higher than level 41, but there's no reason to transcode higher.
+                    if (requestLevel >= 41)
+                    {
+                        return "41";
+                    }
+                }
             }
 
             return level;
@@ -766,6 +826,72 @@ namespace MediaBrowser.Controller.MediaEncoding
             return null;
         }
 
+        public string GetHlsVideoKeyFrameArguments(
+            EncodingJobInfo state,
+            string codec,
+            int segmentLength,
+            bool isEventPlaylist,
+            int? startNumber)
+        {
+            var args = string.Empty;
+            var gopArg = string.Empty;
+            var keyFrameArg = string.Empty;
+            if (isEventPlaylist)
+            {
+                keyFrameArg = string.Format(
+                    CultureInfo.InvariantCulture,
+                    " -force_key_frames:0 \"expr:gte(t,n_forced*{0})\"",
+                    segmentLength);
+            }
+            else if (startNumber.HasValue)
+            {
+                keyFrameArg = string.Format(
+                    CultureInfo.InvariantCulture,
+                    " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"",
+                    startNumber.Value * segmentLength,
+                    segmentLength);
+            }
+
+            var framerate = state.VideoStream?.RealFrameRate;
+            if (framerate.HasValue)
+            {
+                // This is to make sure keyframe interval is limited to our segment,
+                // as forcing keyframes is not enough.
+                // Example: we encoded half of desired length, then codec detected
+                // scene cut and inserted a keyframe; next forced keyframe would
+                // be created outside of segment, which breaks seeking.
+                // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe.
+                gopArg = string.Format(
+                    CultureInfo.InvariantCulture,
+                    " -g:v:0 {0} -keyint_min:v:0 {0} -sc_threshold:v:0 0",
+                    Math.Ceiling(segmentLength * framerate.Value));
+            }
+
+            // Unable to force key frames using these encoders, set key frames by GOP.
+            if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(codec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+            {
+                args += gopArg;
+            }
+            else if (string.Equals(codec, "libx264", StringComparison.OrdinalIgnoreCase)
+                     || string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
+                     || string.Equals(codec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
+                     || string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase))
+            {
+                args += " " + keyFrameArg;
+            }
+            else
+            {
+                args += " " + keyFrameArg + gopArg;
+            }
+
+            return args;
+        }
+
         /// <summary>
         /// Gets the video bitrate to specify on the command line.
         /// </summary>
@@ -773,6 +899,47 @@ namespace MediaBrowser.Controller.MediaEncoding
         {
             var param = string.Empty;
 
+            if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
+                && !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+                && !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
+                && !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+                && !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
+                && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)
+                && !string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
+                && !string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)
+                && !string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
+                && !string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+            {
+                param += " -pix_fmt yuv420p";
+            }
+
+            if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+            {
+                var videoStream = state.VideoStream;
+                var isColorDepth10 = IsColorDepth10(state);
+
+                if (isColorDepth10
+                    && _mediaEncoder.SupportsHwaccel("opencl")
+                    && encodingOptions.EnableTonemapping
+                    && !string.IsNullOrEmpty(videoStream.VideoRange)
+                    && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
+                {
+                    param += " -pix_fmt nv12";
+                }
+                else
+                {
+                    param += " -pix_fmt yuv420p";
+                }
+            }
+
+            if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
+            {
+                param += " -pix_fmt nv21";
+            }
+
             var isVc1 = state.VideoStream != null &&
                 string.Equals(state.VideoStream.Codec, "vc1", StringComparison.OrdinalIgnoreCase);
             var isLibX265 = string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase);
@@ -781,11 +948,11 @@ namespace MediaBrowser.Controller.MediaEncoding
             {
                 if (!string.IsNullOrEmpty(encodingOptions.EncoderPreset))
                 {
-                    param += "-preset " + encodingOptions.EncoderPreset;
+                    param += " -preset " + encodingOptions.EncoderPreset;
                 }
                 else
                 {
-                    param += "-preset " + defaultPreset;
+                    param += " -preset " + defaultPreset;
                 }
 
                 int encodeCrf = encodingOptions.H264Crf;
@@ -809,38 +976,40 @@ namespace MediaBrowser.Controller.MediaEncoding
                     param += " -crf " + defaultCrf;
                 }
             }
-            else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) // h264 (h264_qsv)
+            else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) // h264 (h264_qsv)
+                     || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_qsv)
             {
                 string[] valid_h264_qsv = { "veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast" };
 
                 if (valid_h264_qsv.Contains(encodingOptions.EncoderPreset, StringComparer.OrdinalIgnoreCase))
                 {
-                    param += "-preset " + encodingOptions.EncoderPreset;
+                    param += " -preset " + encodingOptions.EncoderPreset;
                 }
                 else
                 {
-                    param += "-preset 7";
+                    param += " -preset 7";
                 }
 
                 param += " -look_ahead 0";
             }
             else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc)
-                || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase))
+                     || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_nvenc)
             {
+                // following preset will be deprecated in ffmpeg 4.4, use p1~p7 instead.
                 switch (encodingOptions.EncoderPreset)
                 {
                     case "veryslow":
 
-                        param += "-preset slow"; // lossless is only supported on maxwell and newer(2014+)
+                        param += " -preset slow"; // lossless is only supported on maxwell and newer(2014+)
                         break;
 
                     case "slow":
                     case "slower":
-                        param += "-preset slow";
+                        param += " -preset slow";
                         break;
 
                     case "medium":
-                        param += "-preset medium";
+                        param += " -preset medium";
                         break;
 
                     case "fast":
@@ -848,27 +1017,27 @@ namespace MediaBrowser.Controller.MediaEncoding
                     case "veryfast":
                     case "superfast":
                     case "ultrafast":
-                        param += "-preset fast";
+                        param += " -preset fast";
                         break;
 
                     default:
-                        param += "-preset default";
+                        param += " -preset default";
                         break;
                 }
             }
-            else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+            else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf)
+                     || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_amf)
             {
                 switch (encodingOptions.EncoderPreset)
                 {
                     case "veryslow":
                     case "slow":
                     case "slower":
-                        param += "-quality quality";
+                        param += " -quality quality";
                         break;
 
                     case "medium":
-                        param += "-quality balanced";
+                        param += " -quality balanced";
                         break;
 
                     case "fast":
@@ -876,11 +1045,11 @@ namespace MediaBrowser.Controller.MediaEncoding
                     case "veryfast":
                     case "superfast":
                     case "ultrafast":
-                        param += "-quality speed";
+                        param += " -quality speed";
                         break;
 
                     default:
-                        param += "-quality speed";
+                        param += " -quality speed";
                         break;
                 }
 
@@ -896,6 +1065,11 @@ namespace MediaBrowser.Controller.MediaEncoding
                     // Enhance workload when tone mapping with AMF on some APUs
                     param += " -preanalysis true";
                 }
+
+                if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+                {
+                    param += " -header_insertion_mode gop -gops_per_idr 1";
+                }
             }
             else if (string.Equals(videoEncoder, "libvpx", StringComparison.OrdinalIgnoreCase)) // webm
             {
@@ -917,7 +1091,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 profileScore = Math.Min(profileScore, 2);
 
                 // http://www.webmproject.org/docs/encoder-parameters/
-                param += string.Format(CultureInfo.InvariantCulture, "-speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}",
+                param += string.Format(CultureInfo.InvariantCulture, " -speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}",
                     profileScore.ToString(_usCulture),
                     crf,
                     qmin,
@@ -925,15 +1099,15 @@ namespace MediaBrowser.Controller.MediaEncoding
             }
             else if (string.Equals(videoEncoder, "mpeg4", StringComparison.OrdinalIgnoreCase))
             {
-                param += "-mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2";
+                param += " -mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2";
             }
             else if (string.Equals(videoEncoder, "wmv2", StringComparison.OrdinalIgnoreCase)) // asf/wmv
             {
-                param += "-qmin 2";
+                param += " -qmin 2";
             }
             else if (string.Equals(videoEncoder, "msmpeg4", StringComparison.OrdinalIgnoreCase))
             {
-                param += "-mbd 2";
+                param += " -mbd 2";
             }
 
             param += GetVideoBitrateParam(state, videoEncoder);
@@ -945,11 +1119,25 @@ namespace MediaBrowser.Controller.MediaEncoding
             }
 
             var targetVideoCodec = state.ActualOutputVideoCodec;
+            if (string.Equals(targetVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(targetVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+            {
+                targetVideoCodec = "hevc";
+            }
 
             var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault();
+            profile =  Regex.Replace(profile, @"\s+", String.Empty);
 
-            // vaapi does not support Baseline profile, force Constrained Baseline in this case,
-            // which is compatible (and ugly)
+            // Only libx264 support encoding H264 High 10 Profile, otherwise force High Profile.
+            if (!string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
+                && profile != null
+                && profile.IndexOf("high 10", StringComparison.OrdinalIgnoreCase) != -1)
+            {
+                profile = "high";
+            }
+
+            // h264_vaapi does not support Baseline profile, force Constrained Baseline in this case,
+            // which is compatible (and ugly).
             if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
                 && profile != null
                 && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1)
@@ -957,13 +1145,31 @@ namespace MediaBrowser.Controller.MediaEncoding
                 profile = "constrained_baseline";
             }
 
+            // libx264, h264_qsv and h264_nvenc does not support Constrained Baseline profile, force Baseline in this case.
+            if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
+                 || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+                 || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase))
+                && profile != null
+                && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1)
+            {
+                profile = "baseline";
+            }
+
+            // Currently hevc_amf only support encoding HEVC Main Profile, otherwise force Main Profile.
+            if (!string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)
+                && profile != null
+                && profile.IndexOf("main 10", StringComparison.OrdinalIgnoreCase) != -1)
+            {
+                profile = "main";
+            }
+
             if (!string.IsNullOrEmpty(profile))
             {
                 if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
                     && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
                 {
                     // not supported by h264_omx
-                    param += " -profile:v " + profile;
+                    param += " -profile:v:0 " + profile;
                 }
             }
 
@@ -971,55 +1177,35 @@ namespace MediaBrowser.Controller.MediaEncoding
 
             if (!string.IsNullOrEmpty(level))
             {
-                level = NormalizeTranscodingLevel(state.OutputVideoCodec, level);
+                level = NormalizeTranscodingLevel(state, level);
 
-                // h264_qsv and h264_nvenc expect levels to be expressed as a decimal. libx264 supports decimal and non-decimal format
-                // also needed for libx264 due to https://trac.ffmpeg.org/ticket/3307
+                // libx264, QSV, AMF, VAAPI can adjust the given level to match the output.
                 if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
+                    || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase))
                 {
-                    switch (level)
+                    param += " -level " + level;
+                }
+                else if (string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
+                {
+                    // hevc_qsv use -level 51 instead of -level 153.
+                    if (double.TryParse(level, NumberStyles.Any, _usCulture, out double hevcLevel))
                     {
-                        case "30":
-                            param += " -level 3.0";
-                            break;
-                        case "31":
-                            param += " -level 3.1";
-                            break;
-                        case "32":
-                            param += " -level 3.2";
-                            break;
-                        case "40":
-                            param += " -level 4.0";
-                            break;
-                        case "41":
-                            param += " -level 4.1";
-                            break;
-                        case "42":
-                            param += " -level 4.2";
-                            break;
-                        case "50":
-                            param += " -level 5.0";
-                            break;
-                        case "51":
-                            param += " -level 5.1";
-                            break;
-                        case "52":
-                            param += " -level 5.2";
-                            break;
-                        default:
-                            param += " -level " + level;
-                            break;
+                        param += " -level " + hevcLevel / 3;
                     }
                 }
+                else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
+                         || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+                {
+                    param += " -level " + level;
+                }
                 else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase))
+                         || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase))
                 {
-                    // nvenc doesn't decode with param -level set ?!
-                    // TODO:
+                    // level option may cause NVENC to fail.
+                    // NVENC cannot adjust the given level, just throw an error.
                 }
-                else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase))
+                else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
+                         || !string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
                 {
                     param += " -level " + level;
                 }
@@ -1032,42 +1218,11 @@ namespace MediaBrowser.Controller.MediaEncoding
 
             if (string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
             {
-                // todo
-            }
-
-            if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
-                && !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
-                && !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
-                && !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
-                && !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
-                && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
-            {
-                param = "-pix_fmt yuv420p " + param;
-            }
-
-            if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase))
-            {
-                var videoStream = state.VideoStream;
-                var isColorDepth10 = IsColorDepth10(state);
-
-                if (isColorDepth10
-                    && _mediaEncoder.SupportsHwaccel("opencl")
-                    && encodingOptions.EnableTonemapping
-                    && !string.IsNullOrEmpty(videoStream.VideoRange)
-                    && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
-                {
-                    param = "-pix_fmt nv12 " + param;
-                }
-                else
-                {
-                    param = "-pix_fmt yuv420p " + param;
-                }
-            }
-
-            if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
-            {
-                param = "-pix_fmt nv21 " + param;
+                // libx265 only accept level option in -x265-params.
+                // level option may cause libx265 to fail.
+                // libx265 cannot adjust the given level, just throw an error.
+                // TODO: set fine tuned params.
+                param += " -x265-params:0 no-info=1";
             }
 
             return param;
@@ -1346,7 +1501,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)
                 || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))
             {
-                return .5;
+                return .6;
             }
 
             return 1;
@@ -1380,36 +1535,48 @@ namespace MediaBrowser.Controller.MediaEncoding
 
         public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream)
         {
-            if (audioStream == null)
-            {
-                return null;
-            }
-
-            if (request.AudioBitRate.HasValue)
-            {
-                // Don't encode any higher than this
-                return Math.Min(384000, request.AudioBitRate.Value);
-            }
-
-            // Empty bitrate area is not allow on iOS
-            // Default audio bitrate to 128K if it is not being requested
-            // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options
-            return 128000;
+            return GetAudioBitrateParam(request.AudioBitRate, request.AudioCodec, audioStream);
         }
 
-        public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream)
+        public int? GetAudioBitrateParam(int? audioBitRate, string audioCodec, MediaStream audioStream)
         {
             if (audioStream == null)
             {
                 return null;
             }
 
-            if (audioBitRate.HasValue)
+            if (audioBitRate.HasValue && string.IsNullOrEmpty(audioCodec))
             {
-                // Don't encode any higher than this
                 return Math.Min(384000, audioBitRate.Value);
             }
 
+            if (audioBitRate.HasValue && !string.IsNullOrEmpty(audioCodec))
+            {
+                if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
+                {
+                    if ((audioStream.Channels ?? 0) >= 6)
+                    {
+                        return Math.Min(640000, audioBitRate.Value);
+                    }
+
+                    return Math.Min(384000, audioBitRate.Value);
+                }
+
+                if (string.Equals(audioCodec, "flac", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(audioCodec, "alac", StringComparison.OrdinalIgnoreCase))
+                {
+                    if ((audioStream.Channels ?? 0) >= 6)
+                    {
+                        return Math.Min(3584000, audioBitRate.Value);
+                    }
+
+                    return Math.Min(1536000, audioBitRate.Value);
+                }
+            }
+
             // Empty bitrate area is not allow on iOS
             // Default audio bitrate to 128K if it is not being requested
             // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options
@@ -1447,7 +1614,7 @@ namespace MediaBrowser.Controller.MediaEncoding
 
             if (filters.Count > 0)
             {
-                return "-af \"" + string.Join(",", filters) + "\"";
+                return " -af \"" + string.Join(",", filters) + "\"";
             }
 
             return string.Empty;
@@ -1462,6 +1629,11 @@ namespace MediaBrowser.Controller.MediaEncoding
         /// <returns>System.Nullable{System.Int32}.</returns>
         public int? GetNumAudioChannelsParam(EncodingJobInfo state, MediaStream audioStream, string outputAudioCodec)
         {
+            if (audioStream == null)
+            {
+                return null;
+            }
+
             var request = state.BaseRequest;
 
             var inputChannels = audioStream?.Channels;
@@ -1484,6 +1656,11 @@ namespace MediaBrowser.Controller.MediaEncoding
                 // libmp3lame currently only supports two channel output
                 transcoderChannelLimit = 2;
             }
+            else if (codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1)
+            {
+                // aac is able to handle 8ch(7.1 layout)
+                transcoderChannelLimit = 8;
+            }
             else
             {
                 // If we don't have any media info then limit it to 6 to prevent encoding errors due to asking for too many channels
@@ -1708,7 +1885,8 @@ namespace MediaBrowser.Controller.MediaEncoding
                 }
 
                 // For QSV, feed it into hardware encoder now
-                if (isLinux && string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
+                if (isLinux && (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)))
                 {
                     videoSizeParam += ",hwupload=extra_hw_frames=64";
                 }
@@ -1729,7 +1907,8 @@ namespace MediaBrowser.Controller.MediaEncoding
                 : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\"";
 
             // When the input may or may not be hardware VAAPI decodable
-            if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase))
+            if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
+                || string.Equals(outputVideoCodec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase))
             {
                 /*
                     [base]: HW scaling video to OutputSize
@@ -1741,7 +1920,8 @@ namespace MediaBrowser.Controller.MediaEncoding
 
             // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first
             else if (_mediaEncoder.SupportsHwaccel("vaapi") && videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1
-                && string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase))
+                         && (string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase)
+                                 || string.Equals(outputVideoCodec, "libx265", StringComparison.OrdinalIgnoreCase)))
             {
                 /*
                     [base]: SW scaling video to OutputSize
@@ -1750,7 +1930,8 @@ namespace MediaBrowser.Controller.MediaEncoding
                 */
                 retStr = " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay\"";
             }
-            else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
+            else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+                     || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
             {
                 /*
                     QSV in FFMpeg can now setup hardware overlay for transcodes.
@@ -1776,7 +1957,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 videoSizeParam);
         }
 
-        private (int? width, int? height) GetFixedOutputSize(
+        public static (int? width, int? height) GetFixedOutputSize(
             int? videoWidth,
             int? videoHeight,
             int? requestedWidth,
@@ -1836,7 +2017,9 @@ namespace MediaBrowser.Controller.MediaEncoding
                 requestedMaxHeight);
 
             if ((string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase))
+                 || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+                 || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)
+                 || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
                 && width.HasValue
                 && height.HasValue)
             {
@@ -1845,7 +2028,8 @@ namespace MediaBrowser.Controller.MediaEncoding
                 // output dimensions. Output dimensions are guaranteed to be even.
                 var outputWidth = width.Value;
                 var outputHeight = height.Value;
-                var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase);
+                var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase);
                 var isDeintEnabled = state.DeInterlace("h264", true)
                     || state.DeInterlace("avc", true)
                     || state.DeInterlace("h265", true)
@@ -2107,10 +2291,13 @@ namespace MediaBrowser.Controller.MediaEncoding
             var isD3d11vaDecoder = videoDecoder.IndexOf("d3d11va", StringComparison.OrdinalIgnoreCase) != -1;
             var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1;
             var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1;
+            var isVaapiHevcEncoder = outputVideoCodec.IndexOf("hevc_vaapi", StringComparison.OrdinalIgnoreCase) != -1;
             var isQsvH264Encoder = outputVideoCodec.IndexOf("h264_qsv", StringComparison.OrdinalIgnoreCase) != -1;
+            var isQsvHevcEncoder = outputVideoCodec.IndexOf("hevc_qsv", StringComparison.OrdinalIgnoreCase) != -1;
             var isNvdecH264Decoder = videoDecoder.IndexOf("h264_cuvid", StringComparison.OrdinalIgnoreCase) != -1;
             var isNvdecHevcDecoder = videoDecoder.IndexOf("hevc_cuvid", StringComparison.OrdinalIgnoreCase) != -1;
             var isLibX264Encoder = outputVideoCodec.IndexOf("libx264", StringComparison.OrdinalIgnoreCase) != -1;
+            var isLibX265Encoder = outputVideoCodec.IndexOf("libx265", StringComparison.OrdinalIgnoreCase) != -1;
             var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
             var isColorDepth10 = IsColorDepth10(state);
 
@@ -2185,6 +2372,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                     filters.Add("hwdownload");
 
                     if (isLibX264Encoder
+                        || isLibX265Encoder
                         || hasGraphicalSubs
                         || (isNvdecHevcDecoder && isDeinterlaceHevc)
                         || (!isNvdecHevcDecoder && isDeinterlaceH264 || isDeinterlaceHevc))
@@ -2195,20 +2383,20 @@ namespace MediaBrowser.Controller.MediaEncoding
             }
 
             // When the input may or may not be hardware VAAPI decodable
-            if (isVaapiH264Encoder)
+            if (isVaapiH264Encoder || isVaapiHevcEncoder)
             {
                 filters.Add("format=nv12|vaapi");
                 filters.Add("hwupload");
             }
 
             // When burning in graphical subtitles using overlay_qsv, upload videostream to the same qsv context
-            else if (isLinux && hasGraphicalSubs && isQsvH264Encoder)
+            else if (isLinux && hasGraphicalSubs && (isQsvH264Encoder || isQsvHevcEncoder))
             {
                 filters.Add("hwupload=extra_hw_frames=64");
             }
 
             // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first
-            else if (IsVaapiSupported(state) && isVaapiDecoder && isLibX264Encoder)
+            else if (IsVaapiSupported(state) && isVaapiDecoder && (isLibX264Encoder || isLibX265Encoder))
             {
                 var codec = videoStream.Codec.ToLowerInvariant();
 
@@ -2250,7 +2438,9 @@ namespace MediaBrowser.Controller.MediaEncoding
             // Add software deinterlace filter before scaling filter
             if ((isDeinterlaceH264 || isDeinterlaceHevc)
                 && !isVaapiH264Encoder
+                && !isVaapiHevcEncoder
                 && !isQsvH264Encoder
+                && !isQsvHevcEncoder
                 && !isNvdecH264Decoder)
             {
                 if (string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase))
@@ -2289,7 +2479,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             }
 
             // Add parameters to use VAAPI with burn-in text subtitles (GH issue #642)
-            if (isVaapiH264Encoder)
+            if (isVaapiH264Encoder || isVaapiHevcEncoder)
             {
                 if (hasTextSubs)
                 {
@@ -2562,6 +2752,7 @@ namespace MediaBrowser.Controller.MediaEncoding
 
         public void AttachMediaSourceInfo(
             EncodingJobInfo state,
+            EncodingOptions encodingOptions,
             MediaSourceInfo mediaSource,
             string requestedUrl)
         {
@@ -2692,11 +2883,23 @@ namespace MediaBrowser.Controller.MediaEncoding
                 request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => _mediaEncoder.CanEncodeToAudioCodec(i))
                     ?? state.SupportedAudioCodecs.FirstOrDefault();
             }
+
+            var supportedVideoCodecs = state.SupportedVideoCodecs;
+            if (request != null && supportedVideoCodecs != null && supportedVideoCodecs.Length > 0)
+            {
+                var supportedVideoCodecsList = supportedVideoCodecs.ToList();
+
+                ShiftVideoCodecsIfNeeded(supportedVideoCodecsList, encodingOptions);
+
+                state.SupportedVideoCodecs = supportedVideoCodecsList.ToArray();
+
+                request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault();
+            }
         }
 
         private void ShiftAudioCodecsIfNeeded(List<string> audioCodecs, MediaStream audioStream)
         {
-            // Nothing to do here
+            // No need to shift if there is only one supported audio codec.
             if (audioCodecs.Count < 2)
             {
                 return;
@@ -2724,6 +2927,34 @@ namespace MediaBrowser.Controller.MediaEncoding
             }
         }
 
+        private void ShiftVideoCodecsIfNeeded(List<string> videoCodecs, EncodingOptions encodingOptions)
+        {
+            // Shift hevc/h265 to the end of list if hevc encoding is not allowed.
+            if (encodingOptions.AllowHevcEncoding)
+            {
+                return;
+            }
+
+            // No need to shift if there is only one supported video codec.
+            if (videoCodecs.Count < 2)
+            {
+                return;
+            }
+
+            var shiftVideoCodecs = new[] { "hevc", "h265" };
+            if (videoCodecs.All(i => shiftVideoCodecs.Contains(i, StringComparer.OrdinalIgnoreCase)))
+            {
+                return;
+            }
+
+            while (shiftVideoCodecs.Contains(videoCodecs[0], StringComparer.OrdinalIgnoreCase))
+            {
+                var removed = shiftVideoCodecs[0];
+                videoCodecs.RemoveAt(0);
+                videoCodecs.Add(removed);
+            }
+        }
+
         private void NormalizeSubtitleEmbed(EncodingJobInfo state)
         {
             if (state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Embed)
@@ -3357,7 +3588,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 args += " -ar " + state.OutputAudioSampleRate.Value.ToString(_usCulture);
             }
 
-            args += " " + GetAudioFilterParam(state, encodingOptions, false);
+            args += GetAudioFilterParam(state, encodingOptions, false);
 
             return args;
         }

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

@@ -593,6 +593,11 @@ namespace MediaBrowser.Controller.MediaEncoding
         {
             get
             {
+                if (VideoStream == null)
+                {
+                    return null;
+                }
+
                 if (EncodingHelper.IsCopyCodec(OutputVideoCodec))
                 {
                     return VideoStream?.Codec;
@@ -606,6 +611,11 @@ namespace MediaBrowser.Controller.MediaEncoding
         {
             get
             {
+                if (AudioStream == null)
+                {
+                    return null;
+                }
+
                 if (EncodingHelper.IsCopyCodec(OutputAudioCodec))
                 {
                     return AudioStream?.Codec;

+ 1 - 1
MediaBrowser.Controller/Playlists/IPlaylistManager.cs

@@ -31,7 +31,7 @@ namespace MediaBrowser.Controller.Playlists
         /// <param name="itemIds">The item ids.</param>
         /// <param name="userId">The user identifier.</param>
         /// <returns>Task.</returns>
-        Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId);
+        Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId);
 
         /// <summary>
         /// Removes from playlist.

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