浏览代码

Reduce RequestHelpers.Split usage and remove RequestHelpers.GetGuids usage.

crobibero 4 年之前
父节点
当前提交
3cc0dd7e12
共有 36 个文件被更改,包括 659 次插入288 次删除
  1. 1 1
      Emby.Server.Implementations/Channels/ChannelManager.cs
  2. 4 4
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  3. 1 1
      Emby.Server.Implementations/Library/LibraryManager.cs
  4. 3 3
      Emby.Server.Implementations/Playlists/PlaylistManager.cs
  5. 46 54
      Jellyfin.Api/Controllers/ArtistsController.cs
  6. 11 6
      Jellyfin.Api/Controllers/CollectionController.cs
  7. 5 5
      Jellyfin.Api/Controllers/GenresController.cs
  8. 52 51
      Jellyfin.Api/Controllers/ItemsController.cs
  9. 6 7
      Jellyfin.Api/Controllers/LibraryController.cs
  10. 13 15
      Jellyfin.Api/Controllers/LiveTvController.cs
  11. 5 5
      Jellyfin.Api/Controllers/MusicGenresController.cs
  12. 4 4
      Jellyfin.Api/Controllers/PersonsController.cs
  13. 7 6
      Jellyfin.Api/Controllers/PlaylistsController.cs
  14. 7 6
      Jellyfin.Api/Controllers/SearchController.cs
  15. 4 4
      Jellyfin.Api/Controllers/SessionController.cs
  16. 5 8
      Jellyfin.Api/Controllers/StudiosController.cs
  17. 5 4
      Jellyfin.Api/Controllers/SuggestionsController.cs
  18. 19 19
      Jellyfin.Api/Controllers/TrailersController.cs
  19. 4 4
      Jellyfin.Api/Controllers/UniversalAudioController.cs
  20. 2 2
      Jellyfin.Api/Controllers/UserLibraryController.cs
  21. 4 3
      Jellyfin.Api/Controllers/UserViewsController.cs
  22. 3 2
      Jellyfin.Api/Controllers/VideosController.cs
  23. 7 11
      Jellyfin.Api/Controllers/YearsController.cs
  24. 0 43
      Jellyfin.Api/Helpers/RequestHelpers.cs
  25. 90 0
      Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs
  26. 6 3
      Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
  27. 5 1
      Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
  28. 74 0
      MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs
  29. 28 0
      MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs
  30. 1 1
      MediaBrowser.Common/MediaBrowser.Common.csproj
  31. 3 3
      MediaBrowser.Controller/Entities/Folder.cs
  32. 3 3
      MediaBrowser.Controller/Entities/InternalItemsQuery.cs
  33. 2 2
      MediaBrowser.Controller/Entities/UserViewBuilder.cs
  34. 1 1
      MediaBrowser.Controller/Playlists/IPlaylistManager.cs
  35. 2 6
      MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs
  36. 226 0
      tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs

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

+ 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) &&

+ 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

+ 46 - 54
Jellyfin.Api/Controllers/ArtistsController.cs

@@ -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, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
             [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [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,
@@ -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, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
             [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [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,
@@ -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;

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

@@ -4,6 +4,7 @@ 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();
         }
     }

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

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

+ 52 - 51
Jellyfin.Api/Controllers/ItemsController.cs

@@ -173,7 +173,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 +181,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, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
             [FromQuery] string? studios,
             [FromQuery] string? artists,
-            [FromQuery] string? excludeArtistIds,
-            [FromQuery] string? artistIds,
-            [FromQuery] string? albumArtistIds,
-            [FromQuery] string? contributingArtistIds,
+            [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] string? albums,
-            [FromQuery] string? albumIds,
-            [FromQuery] string? ids,
-            [FromQuery] string? videoTypes,
+            [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,
@@ -223,8 +223,8 @@ namespace Jellyfin.Api.Controllers
             [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)
         {
@@ -238,8 +238,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 +263,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 +292,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 +331,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 +361,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;
                 }
@@ -449,14 +450,14 @@ 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
@@ -533,12 +534,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 +570,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 }

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

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

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

@@ -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,

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

@@ -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();
         }
 

+ 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,

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

@@ -160,12 +160,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 +378,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 +391,7 @@ namespace Jellyfin.Api.Controllers
 
             _sessionManager.ReportCapabilities(id, new ClientCapabilities
             {
-                PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true),
+                PlayableMediaTypes = playableMediaTypes,
                 SupportedCommands = supportedCommands,
                 SupportsMediaControl = supportsMediaControl,
                 SupportsSync = supportsSync,

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

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

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

@@ -4,6 +4,7 @@ using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.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,

+ 19 - 19
Jellyfin.Api/Controllers/TrailersController.cs

@@ -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, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
             [FromQuery] string? studios,
             [FromQuery] string? artists,
-            [FromQuery] string? excludeArtistIds,
-            [FromQuery] string? artistIds,
-            [FromQuery] string? albumArtistIds,
-            [FromQuery] string? contributingArtistIds,
+            [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] string? albums,
-            [FromQuery] string? albumIds,
-            [FromQuery] string? ids,
-            [FromQuery] string? videoTypes,
+            [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,
@@ -188,12 +188,12 @@ namespace Jellyfin.Api.Controllers
             [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(

+ 4 - 4
Jellyfin.Api/Controllers/UniversalAudioController.cs

@@ -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,
@@ -258,7 +259,7 @@ namespace Jellyfin.Api.Controllers
         }
 
         private DeviceProfile GetDeviceProfile(
-            string? container,
+            string[] containers,
             string? transcodingContainer,
             string? audioCodec,
             string? transcodingProtocol,
@@ -270,7 +271,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 +327,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();

+ 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,

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

@@ -5,6 +5,7 @@ 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;

+ 3 - 2
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)

+ 7 - 11
Jellyfin.Api/Controllers/YearsController.cs

@@ -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)
             {

+ 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,

+ 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.

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

@@ -0,0 +1,74 @@
+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 .Net5
+                        // _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 - 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>
 

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

@@ -1067,12 +1067,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;
             }
@@ -1177,7 +1177,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);

+ 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.

+ 2 - 6
MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs

@@ -2,6 +2,7 @@
 #pragma warning disable CS1591
 
 using System;
+using System.Collections.Generic;
 
 namespace MediaBrowser.Model.Playlists
 {
@@ -9,15 +10,10 @@ namespace MediaBrowser.Model.Playlists
     {
         public string Name { get; set; }
 
-        public Guid[] ItemIdList { get; set; }
+        public IReadOnlyList<Guid> ItemIdList { get; set; } = Array.Empty<Guid>();
 
         public string MediaType { get; set; }
 
         public Guid UserId { get; set; }
-
-        public PlaylistCreationRequest()
-        {
-            ItemIdList = Array.Empty<Guid>();
-        }
     }
 }

+ 226 - 0
tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs

@@ -0,0 +1,226 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Api.ModelBinders;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Primitives;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.ModelBinders
+{
+    public sealed class PipeDelimitedArrayModelBinderTests
+    {
+        [Fact]
+        public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedStringArrayQuery()
+        {
+            var queryParamName = "test";
+            IReadOnlyList<string> queryParamValues = new[] { "lol", "xd" };
+            var queryParamString = "lol|xd";
+            var queryParamType = typeof(string[]);
+
+            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var valueProvider = new QueryStringValueProvider(
+                    new BindingSource(string.Empty, string.Empty, false, false),
+                    new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+                    CultureInfo.InvariantCulture);
+            var bindingContextMock = new Mock<ModelBindingContext>();
+            bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+            bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+            bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+            bindingContextMock.SetupProperty(b => b.Result);
+
+            await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+            Assert.True(bindingContextMock.Object.Result.IsModelSet);
+            Assert.Equal((IReadOnlyList<string>?)bindingContextMock.Object?.Result.Model, queryParamValues);
+        }
+
+        [Fact]
+        public async Task BindModelAsync_CorrectlyBindsValidDelimitedIntArrayQuery()
+        {
+            var queryParamName = "test";
+            IReadOnlyList<int> queryParamValues = new[] { 42, 0 };
+            var queryParamString = "42|0";
+            var queryParamType = typeof(int[]);
+
+            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var valueProvider = new QueryStringValueProvider(
+                    new BindingSource(string.Empty, string.Empty, false, false),
+                    new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+                    CultureInfo.InvariantCulture);
+            var bindingContextMock = new Mock<ModelBindingContext>();
+            bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+            bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+            bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+            bindingContextMock.SetupProperty(b => b.Result);
+
+            await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+            Assert.True(bindingContextMock.Object.Result.IsModelSet);
+            Assert.Equal((IReadOnlyList<int>?)bindingContextMock.Object.Result.Model, queryParamValues);
+        }
+
+        [Fact]
+        public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQuery()
+        {
+            var queryParamName = "test";
+            IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much };
+            var queryParamString = "How|Much";
+            var queryParamType = typeof(TestType[]);
+
+            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var valueProvider = new QueryStringValueProvider(
+                    new BindingSource(string.Empty, string.Empty, false, false),
+                    new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+                    CultureInfo.InvariantCulture);
+            var bindingContextMock = new Mock<ModelBindingContext>();
+            bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+            bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+            bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+            bindingContextMock.SetupProperty(b => b.Result);
+
+            await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+            Assert.True(bindingContextMock.Object.Result.IsModelSet);
+            Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+        }
+
+        [Fact]
+        public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQueryWithDoublePipes()
+        {
+            var queryParamName = "test";
+            IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much };
+            var queryParamString = "How||Much";
+            var queryParamType = typeof(TestType[]);
+
+            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var valueProvider = new QueryStringValueProvider(
+                    new BindingSource(string.Empty, string.Empty, false, false),
+                    new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+                    CultureInfo.InvariantCulture);
+            var bindingContextMock = new Mock<ModelBindingContext>();
+            bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+            bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+            bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+            bindingContextMock.SetupProperty(b => b.Result);
+
+            await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+            Assert.True(bindingContextMock.Object.Result.IsModelSet);
+            Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+        }
+
+        [Fact]
+        public async Task BindModelAsync_CorrectlyBindsValidEnumArrayQuery()
+        {
+            var queryParamName = "test";
+            IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much };
+            var queryParamString1 = "How";
+            var queryParamString2 = "Much";
+            var queryParamType = typeof(TestType[]);
+
+            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+
+            var valueProvider = new QueryStringValueProvider(
+                    new BindingSource(string.Empty, string.Empty, false, false),
+                    new QueryCollection(new Dictionary<string, StringValues>
+                    {
+                        { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) },
+                    }),
+                    CultureInfo.InvariantCulture);
+            var bindingContextMock = new Mock<ModelBindingContext>();
+            bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+            bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+            bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+            bindingContextMock.SetupProperty(b => b.Result);
+
+            await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+            Assert.True(bindingContextMock.Object.Result.IsModelSet);
+            Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+        }
+
+        [Fact]
+        public async Task BindModelAsync_CorrectlyBindsEmptyEnumArrayQuery()
+        {
+            var queryParamName = "test";
+            IReadOnlyList<TestType> queryParamValues = Array.Empty<TestType>();
+            var queryParamType = typeof(TestType[]);
+
+            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+
+            var valueProvider = new QueryStringValueProvider(
+                    new BindingSource(string.Empty, string.Empty, false, false),
+                    new QueryCollection(new Dictionary<string, StringValues>
+                    {
+                        { queryParamName, new StringValues(value: null) },
+                    }),
+                    CultureInfo.InvariantCulture);
+            var bindingContextMock = new Mock<ModelBindingContext>();
+            bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+            bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+            bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+            bindingContextMock.SetupProperty(b => b.Result);
+
+            await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+            Assert.True(bindingContextMock.Object.Result.IsModelSet);
+            Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+        }
+
+        [Fact]
+        public async Task BindModelAsync_EnumArrayQuery_BindValidOnly()
+        {
+            var queryParamName = "test";
+            var queryParamString = "🔥|😢";
+            var queryParamType = typeof(IReadOnlyList<TestType>);
+
+            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var valueProvider = new QueryStringValueProvider(
+                    new BindingSource(string.Empty, string.Empty, false, false),
+                    new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+                    CultureInfo.InvariantCulture);
+            var bindingContextMock = new Mock<ModelBindingContext>();
+            bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+            bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+            bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+            bindingContextMock.SetupProperty(b => b.Result);
+
+            await modelBinder.BindModelAsync(bindingContextMock.Object);
+            Assert.True(bindingContextMock.Object.Result.IsModelSet);
+            Assert.Empty((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model);
+        }
+
+        [Fact]
+        public async Task BindModelAsync_EnumArrayQuery_BindValidOnly_2()
+        {
+            var queryParamName = "test";
+            var queryParamString1 = "How";
+            var queryParamString2 = "😱";
+            var queryParamType = typeof(IReadOnlyList<TestType>);
+
+            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+
+            var valueProvider = new QueryStringValueProvider(
+                    new BindingSource(string.Empty, string.Empty, false, false),
+                    new QueryCollection(new Dictionary<string, StringValues>
+                    {
+                        { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) },
+                    }),
+                    CultureInfo.InvariantCulture);
+            var bindingContextMock = new Mock<ModelBindingContext>();
+            bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+            bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+            bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+            bindingContextMock.SetupProperty(b => b.Result);
+
+            await modelBinder.BindModelAsync(bindingContextMock.Object);
+            Assert.True(bindingContextMock.Object.Result.IsModelSet);
+            Assert.Single((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model);
+        }
+    }
+}