Forráskód Böngészése

Merge remote-tracking branch 'jellyfinorigin/master' into feature/DatabaseRefactor

JPVenson 3 hónapja
szülő
commit
d8030147ff
84 módosított fájl, 2561 hozzáadás és 688 törlés
  1. 1 1
      .config/dotnet-tools.json
  2. 3 3
      .github/workflows/ci-codeql-analysis.yml
  3. 1 1
      .github/workflows/ci-tests.yml
  4. 0 88
      .github/workflows/commands.yml
  5. 27 28
      Directory.Packages.props
  6. 4 4
      Emby.Server.Implementations/Library/LibraryManager.cs
  7. 15 3
      Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs
  8. 3 1
      Emby.Server.Implementations/Localization/Core/ar.json
  9. 36 36
      Emby.Server.Implementations/Localization/Core/ca.json
  10. 40 30
      Emby.Server.Implementations/Localization/Core/eu.json
  11. 3 1
      Emby.Server.Implementations/Localization/Core/ht.json
  12. 139 0
      Emby.Server.Implementations/Localization/Core/lb.json
  13. 81 17
      Emby.Server.Implementations/Session/WebSocketController.cs
  14. 34 34
      Jellyfin.Api/Controllers/ArtistsController.cs
  15. 7 7
      Jellyfin.Api/Controllers/ChannelsController.cs
  16. 3 3
      Jellyfin.Api/Controllers/CollectionController.cs
  17. 3 3
      Jellyfin.Api/Controllers/FilterController.cs
  18. 6 6
      Jellyfin.Api/Controllers/GenresController.cs
  19. 16 16
      Jellyfin.Api/Controllers/InstantMixController.cs
  20. 72 72
      Jellyfin.Api/Controllers/ItemsController.cs
  21. 9 9
      Jellyfin.Api/Controllers/LibraryController.cs
  22. 1 1
      Jellyfin.Api/Controllers/LibraryStructureController.cs
  23. 17 17
      Jellyfin.Api/Controllers/LiveTvController.cs
  24. 1 1
      Jellyfin.Api/Controllers/MoviesController.cs
  25. 6 6
      Jellyfin.Api/Controllers/MusicGenresController.cs
  26. 5 5
      Jellyfin.Api/Controllers/PersonsController.cs
  27. 5 5
      Jellyfin.Api/Controllers/PlaylistsController.cs
  28. 3 3
      Jellyfin.Api/Controllers/SearchController.cs
  29. 3 3
      Jellyfin.Api/Controllers/SessionController.cs
  30. 4 4
      Jellyfin.Api/Controllers/StudiosController.cs
  31. 4 4
      Jellyfin.Api/Controllers/SuggestionsController.cs
  32. 30 30
      Jellyfin.Api/Controllers/TrailersController.cs
  33. 8 8
      Jellyfin.Api/Controllers/TvShowsController.cs
  34. 1 1
      Jellyfin.Api/Controllers/UniversalAudioController.cs
  35. 6 6
      Jellyfin.Api/Controllers/UserLibraryController.cs
  36. 2 2
      Jellyfin.Api/Controllers/UserViewsController.cs
  37. 1 1
      Jellyfin.Api/Controllers/VideosController.cs
  38. 7 7
      Jellyfin.Api/Controllers/YearsController.cs
  39. 6 6
      Jellyfin.Api/ModelBinders/CommaDelimitedCollectionModelBinder.cs
  40. 7 7
      Jellyfin.Api/ModelBinders/PipeDelimitedCollectionModelBinder.cs
  41. 7 7
      Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
  42. 1 1
      Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
  43. 1 1
      Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs
  44. 3 2
      Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs
  45. 3 2
      Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs
  46. 1 1
      Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs
  47. 1 0
      Jellyfin.Database/Jellyfin.Database.Providers.SqLite/Migrations/.gitattributes
  48. 1595 0
      Jellyfin.Database/Jellyfin.Database.Providers.SqLite/Migrations/20250214031148_ChannelIdGuid.Designer.cs
  49. 22 0
      Jellyfin.Database/Jellyfin.Database.Providers.SqLite/Migrations/20250214031148_ChannelIdGuid.cs
  50. 2 2
      Jellyfin.Database/Jellyfin.Database.Providers.SqLite/Migrations/JellyfinDbModelSnapshot.cs
  51. 10 8
      Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
  52. 2 2
      Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
  53. 5 1
      Jellyfin.Server.Implementations/Item/PeopleRepository.cs
  54. 1 1
      Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
  55. 2 2
      MediaBrowser.Controller/Channels/Channel.cs
  56. 9 7
      MediaBrowser.Controller/Entities/BaseItem.cs
  57. 2 2
      MediaBrowser.Controller/Entities/CollectionFolder.cs
  58. 3 3
      MediaBrowser.Controller/Entities/Folder.cs
  59. 3 3
      MediaBrowser.Controller/Entities/Movies/BoxSet.cs
  60. 1 1
      MediaBrowser.Controller/Library/ILibraryManager.cs
  61. 2 2
      MediaBrowser.Controller/Playlists/Playlist.cs
  62. 7 1
      MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
  63. 2 2
      MediaBrowser.Model/Dto/ClientCapabilitiesDto.cs
  64. 1 1
      MediaBrowser.Model/Entities/MediaStream.cs
  65. 15 2
      MediaBrowser.Providers/Manager/ItemImageProvider.cs
  66. 3 20
      MediaBrowser.Providers/Manager/ProviderManager.cs
  67. 3 3
      MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
  68. 2 2
      MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
  69. 1 1
      src/Jellyfin.Drawing/ImageProcessor.cs
  70. 4 4
      src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedCollectionConverter.cs
  71. 7 4
      src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedCollectionConverterFactory.cs
  72. 12 26
      src/Jellyfin.Extensions/Json/Converters/JsonDelimitedCollectionConverter.cs
  73. 3 3
      src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedCollectionConverter.cs
  74. 7 4
      src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedCollectionConverterFactory.cs
  75. 46 62
      src/Jellyfin.LiveTv/Guide/GuideManager.cs
  76. 9 9
      tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedCollectionModelBinderTests.cs
  77. 9 9
      tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedCollectionModelBinderTests.cs
  78. 74 1
      tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedCollectionTests.cs
  79. 13 0
      tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs
  80. 1 1
      tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs
  81. 19 0
      tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyCollectionModel.cs
  82. 1 1
      tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs
  83. 22 0
      tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyListModel.cs
  84. 4 4
      tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs

+ 1 - 1
.config/dotnet-tools.json

@@ -3,7 +3,7 @@
   "isRoot": true,
   "tools": {
     "dotnet-ef": {
-      "version": "9.0.1",
+      "version": "9.0.2",
       "commands": [
         "dotnet-ef"
       ]

+ 3 - 3
.github/workflows/ci-codeql-analysis.yml

@@ -27,11 +27,11 @@ jobs:
         dotnet-version: '9.0.x'
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
+      uses: github/codeql-action/init@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
       with:
         languages: ${{ matrix.language }}
         queries: +security-extended
     - name: Autobuild
-      uses: github/codeql-action/autobuild@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
+      uses: github/codeql-action/autobuild@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
+      uses: github/codeql-action/analyze@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9

+ 1 - 1
.github/workflows/ci-tests.yml

@@ -35,7 +35,7 @@ jobs:
           --verbosity minimal
 
       - name: Merge code coverage results
-        uses: danielpalme/ReportGenerator-GitHub-Action@c38c522d4b391c1b0da979cbb2e902c0a252a7dc # v5.4.3
+        uses: danielpalme/ReportGenerator-GitHub-Action@f1927db1dbfc029b056583ee488832e939447fe6 # v5.4.4
         with:
           reports: "**/coverage.cobertura.xml"
           targetdir: "merged/"

+ 0 - 88
.github/workflows/commands.yml

@@ -34,94 +34,6 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
 
-  check-backport:
-    permissions:
-      contents: read
-
-    name: Check Backport
-    if: ${{ ( github.event.issue.pull_request && contains(github.event.comment.body, '@jellyfin-bot check backport') ) || github.event.label.name == 'stable backport' || contains(github.event.pull_request.labels.*.name, 'stable backport' ) }}
-    runs-on: ubuntu-latest
-    steps:
-      - name: Notify as seen
-        uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
-        if: ${{ github.event.comment != null }}
-        with:
-          token: ${{ secrets.JF_BOT_TOKEN }}
-          comment-id: ${{ github.event.comment.id }}
-          reactions: eyes
-
-      - name: Checkout the latest code
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
-        with:
-          token: ${{ secrets.JF_BOT_TOKEN }}
-          fetch-depth: 0
-
-      - name: Notify as running
-        id: comment_running
-        uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
-        if: ${{ github.event.comment != null }}
-        with:
-          token: ${{ secrets.JF_BOT_TOKEN }}
-          issue-number: ${{ github.event.issue.number }}
-          body: |
-            Running backport tests...
-
-      - name: Perform test backport
-        id: run_tests
-        run: |
-          set +o errexit
-          git config --global user.name "Jellyfin Bot"
-          git config --global user.email "team@jellyfin.org"
-          CURRENT_BRANCH="origin/${GITHUB_HEAD_REF}"
-          git checkout master
-          git merge --no-ff ${CURRENT_BRANCH}
-          MERGE_COMMIT_HASH=$( git log -q -1 | head -1 | awk '{ print $2 }' )
-          git fetch --all
-          CURRENT_STABLE=$( git branch -r | grep 'origin/release' | sort -rV | head -1 | awk -F '/' '{ print $NF }' )
-          stable_branch="Current stable release branch: ${CURRENT_STABLE}"
-          echo ${stable_branch}
-          echo ::set-output name=branch::${stable_branch}
-          git checkout -t origin/${CURRENT_STABLE} -b ${CURRENT_STABLE}
-          git cherry-pick -sx -m1 ${MERGE_COMMIT_HASH} &>output.txt
-          retcode=$?
-          cat output.txt | grep -v 'hint:'
-          output="$( grep -v 'hint:'  output.txt )"
-          output="${output//'%'/'%25'}"
-          output="${output//$'\n'/'%0A'}"
-          output="${output//$'\r'/'%0D'}" 
-          echo ::set-output name=output::$output
-          exit ${retcode}
-
-      - name: Notify with result success
-        uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
-        if: ${{ github.event.comment != null && success() }}
-        with:
-          token: ${{ secrets.JF_BOT_TOKEN }}
-          comment-id: ${{ steps.comment_running.outputs.comment-id }}
-          body: |
-            ${{ steps.run_tests.outputs.branch }}
-            Output from `git cherry-pick`:
-
-            ---
-
-            ${{ steps.run_tests.outputs.output }}
-          reactions: hooray
-
-      - name: Notify with result failure
-        uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
-        if: ${{ github.event.comment != null && failure() }}
-        with:
-          token: ${{ secrets.JF_BOT_TOKEN }}
-          comment-id: ${{ steps.comment_running.outputs.comment-id }}
-          body: |
-            ${{ steps.run_tests.outputs.branch }}
-            Output from `git cherry-pick`:
-
-            ---
-
-            ${{ steps.run_tests.outputs.output }}
-          reactions: confused
-
   rename:
     name: Rename
     if: contains(github.event.comment.body, '@jellyfin-bot rename') && github.event.comment.author_association == 'MEMBER'

+ 27 - 28
Directory.Packages.props

@@ -24,31 +24,30 @@
     <PackageVersion Include="libse" Version="4.0.10" />
     <PackageVersion Include="LrcParser" Version="2024.0728.2" />
     <PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
-    <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
+    <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.2" />
     <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
-    <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.1" />
-    <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.1" />
-    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
+    <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.2" />
+    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
     <PackageVersion Include="MimeTypes" Version="2.5.2" />
     <PackageVersion Include="Moq" Version="4.18.4" />
     <PackageVersion Include="NEbml" Version="0.12.0" />
@@ -76,11 +75,11 @@
     <PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
     <PackageVersion Include="System.Globalization" Version="4.3.0" />
     <PackageVersion Include="System.Linq.Async" Version="6.0.1" />
-    <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.1" />
-    <PackageVersion Include="System.Text.Json" Version="9.0.1" />
-    <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.1" />
+    <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.2" />
+    <PackageVersion Include="System.Text.Json" Version="9.0.2" />
+    <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.2" />
     <PackageVersion Include="TagLibSharp" Version="2.3.0" />
-    <PackageVersion Include="z440.atl.core" Version="6.15.0" />
+    <PackageVersion Include="z440.atl.core" Version="6.16.0" />
     <PackageVersion Include="TMDbLib" Version="2.2.0" />
     <PackageVersion Include="UTF.Unknown" Version="2.5.1" />
     <PackageVersion Include="Xunit.Priority" Version="1.1.6" />

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

@@ -1812,11 +1812,11 @@ namespace Emby.Server.Implementations.Library
         /// <inheritdoc />
         public void CreateItem(BaseItem item, BaseItem? parent)
         {
-            CreateOrUpdateItems(new[] { item }, parent, CancellationToken.None);
+            CreateItems(new[] { item }, parent, CancellationToken.None);
         }
 
         /// <inheritdoc />
-        public void CreateOrUpdateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken)
+        public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken)
         {
             _itemRepository.SaveItems(items, cancellationToken);
 
@@ -2973,11 +2973,11 @@ namespace Emby.Server.Implementations.Library
                 {
                     if (createEntity)
                     {
-                        CreateOrUpdateItems([personEntity], null, CancellationToken.None);
+                        CreateItems([personEntity], null, CancellationToken.None);
                     }
 
                     await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
-                    CreateOrUpdateItems([personEntity], null, CancellationToken.None);
+                    CreateItems([personEntity], null, CancellationToken.None);
                 }
             }
         }

+ 15 - 3
Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs

@@ -43,14 +43,26 @@ public class SplashscreenPostScanTask : ILibraryPostScanTask
     /// <inheritdoc />
     public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
     {
-        var posters = GetItemsWithImageType(ImageType.Primary).Select(x => x.GetImages(ImageType.Primary).First().Path).ToList();
-        var backdrops = GetItemsWithImageType(ImageType.Thumb).Select(x => x.GetImages(ImageType.Thumb).First().Path).ToList();
+        var posters = GetItemsWithImageType(ImageType.Primary)
+            .Select(x => x.GetImages(ImageType.Primary).FirstOrDefault()?.Path)
+            .Where(path => !string.IsNullOrEmpty(path))
+            .Select(path => path!)
+            .ToList();
+        var backdrops = GetItemsWithImageType(ImageType.Thumb)
+            .Select(x => x.GetImages(ImageType.Thumb).FirstOrDefault()?.Path)
+            .Where(path => !string.IsNullOrEmpty(path))
+            .Select(path => path!)
+            .ToList();
         if (backdrops.Count == 0)
         {
             // Thumb images fit better because they include the title in the image but are not provided with TMDb.
             // Using backdrops as a fallback to generate an image at all
             _logger.LogDebug("No thumb images found. Using backdrops to generate splashscreen");
-            backdrops = GetItemsWithImageType(ImageType.Backdrop).Select(x => x.GetImages(ImageType.Backdrop).First().Path).ToList();
+            backdrops = GetItemsWithImageType(ImageType.Backdrop)
+                .Select(x => x.GetImages(ImageType.Backdrop).FirstOrDefault()?.Path)
+                .Where(path => !string.IsNullOrEmpty(path))
+                .Select(path => path!)
+                .ToList();
         }
 
         _imageEncoder.CreateSplashscreen(posters, backdrops);

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

@@ -134,5 +134,7 @@
     "TaskDownloadMissingLyrics": "تنزيل عبارات القصيدة",
     "TaskDownloadMissingLyricsDescription": "كلمات",
     "TaskExtractMediaSegments": "فحص مقاطع الوسائط",
-    "TaskExtractMediaSegmentsDescription": "وسائط"
+    "TaskExtractMediaSegmentsDescription": "يستخرج مقاطع وسائط من إضافات MediaSegment المُفعّلة.",
+    "TaskMoveTrickplayImages": "تغيير مكان صور المعاينة السريعة",
+    "TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة."
 }

+ 36 - 36
Emby.Server.Implementations/Localization/Core/ca.json

@@ -16,7 +16,7 @@
     "Folders": "Carpetes",
     "Genres": "Gèneres",
     "HeaderAlbumArtists": "Artistes de l'àlbum",
-    "HeaderContinueWatching": "Continuar veient",
+    "HeaderContinueWatching": "Continua veient",
     "HeaderFavoriteAlbums": "Àlbums preferits",
     "HeaderFavoriteArtists": "Artistes preferits",
     "HeaderFavoriteEpisodes": "Episodis preferits",
@@ -24,13 +24,13 @@
     "HeaderFavoriteSongs": "Cançons preferides",
     "HeaderLiveTV": "TV en directe",
     "HeaderNextUp": "A continuació",
-    "HeaderRecordingGroups": "Grups d'enregistrament",
+    "HeaderRecordingGroups": "Grups Musicals",
     "HomeVideos": "Vídeos domèstics",
-    "Inherit": "Hereta",
-    "ItemAddedWithName": "{0} ha sigut afegit a la biblioteca",
-    "ItemRemovedWithName": "{0} ha sigut eliminat de la biblioteca",
+    "Inherit": "Heretat",
+    "ItemAddedWithName": "{0} s'ha afegit a la biblioteca",
+    "ItemRemovedWithName": "{0} s'ha eliminat de la biblioteca",
     "LabelIpAddressValue": "Adreça IP: {0}",
-    "LabelRunningTimeValue": "Temps en funcionament: {0}",
+    "LabelRunningTimeValue": "Temps en marxa: {0}",
     "Latest": "Darrers",
     "MessageApplicationUpdated": "El servidor de Jellyfin ha estat actualitzat",
     "MessageApplicationUpdatedTo": "El servidor de Jellyfin ha estat actualitzat a {0}",
@@ -44,8 +44,8 @@
     "NameSeasonNumber": "Temporada {0}",
     "NameSeasonUnknown": "Temporada desconeguda",
     "NewVersionIsAvailable": "Una nova versió del servidor de Jellyfin està disponible per a descarregar.",
-    "NotificationOptionApplicationUpdateAvailable": "Actualització de l'aplicació disponible",
-    "NotificationOptionApplicationUpdateInstalled": "Actualització de l'aplicació instal·lada",
+    "NotificationOptionApplicationUpdateAvailable": "Actualització de l'aplicatiu disponible",
+    "NotificationOptionApplicationUpdateInstalled": "Actualització de l'aplicatiu instal·lada",
     "NotificationOptionAudioPlayback": "Reproducció d'àudio iniciada",
     "NotificationOptionAudioPlaybackStopped": "Reproducció d'àudio aturada",
     "NotificationOptionCameraImageUploaded": "Imatge de càmera pujada",
@@ -54,8 +54,8 @@
     "NotificationOptionPluginError": "Un complement ha fallat",
     "NotificationOptionPluginInstalled": "Complement instal·lat",
     "NotificationOptionPluginUninstalled": "Complement desinstal·lat",
-    "NotificationOptionPluginUpdateInstalled": "Actualització de complement instal·lada",
-    "NotificationOptionServerRestartRequired": "Reinici del servidor requerit",
+    "NotificationOptionPluginUpdateInstalled": "Actualització del complement instal·lada",
+    "NotificationOptionServerRestartRequired": "El servidor s'ha de reiniciar",
     "NotificationOptionTaskFailed": "Tasca programada fallida",
     "NotificationOptionUserLockedOut": "Usuari expulsat",
     "NotificationOptionVideoPlayback": "Reproducció de vídeo iniciada",
@@ -64,15 +64,15 @@
     "Playlists": "Llistes de reproducció",
     "Plugin": "Complement",
     "PluginInstalledWithName": "{0} ha estat instal·lat",
-    "PluginUninstalledWithName": "{0} ha estat desinstal·lat",
-    "PluginUpdatedWithName": "{0} ha estat actualitzat",
+    "PluginUninstalledWithName": "S'ha instalat {0}",
+    "PluginUpdatedWithName": "S'ha actualitzat {0}",
     "ProviderValue": "Proveïdor: {0}",
     "ScheduledTaskFailedWithName": "{0} ha fallat",
-    "ScheduledTaskStartedWithName": "{0} s'ha iniciat",
-    "ServerNameNeedsToBeRestarted": "{0} necessita ser reiniciat",
+    "ScheduledTaskStartedWithName": "S'ha iniciat {0}",
+    "ServerNameNeedsToBeRestarted": "S'ha de reiniciar {0}",
     "Shows": "Sèries",
     "Songs": "Cançons",
-    "StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu-ho altre cop aviat.",
+    "StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu de nou en una estona.",
     "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
     "SubtitleDownloadFailureFromForItem": "Els subtítols per a {1} no s'han pogut baixar de {0}",
     "Sync": "Sincronitzar",
@@ -80,41 +80,41 @@
     "TvShows": "Sèries de TV",
     "User": "Usuari",
     "UserCreatedWithName": "S'ha creat l'usuari {0}",
-    "UserDeletedWithName": "L'usuari {0} ha estat eliminat",
+    "UserDeletedWithName": "S'ha eliminat l'usuari {0}",
     "UserDownloadingItemWithValues": "{0} està descarregant {1}",
-    "UserLockedOutWithName": "L'usuari {0} ha sigut expulsat",
+    "UserLockedOutWithName": "S'ha expulsat a l'usuari {0}",
     "UserOfflineFromDevice": "{0} s'ha desconnectat de {1}",
     "UserOnlineFromDevice": "{0} està connectat des de {1}",
-    "UserPasswordChangedWithName": "La contrasenya ha estat canviada per a l'usuari {0}",
+    "UserPasswordChangedWithName": "S'ha canviat la contrasenya per a l'usuari {0}",
     "UserPolicyUpdatedWithName": "La política d'usuari s'ha actualitzat per a {0}",
-    "UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1}",
-    "UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1}",
-    "ValueHasBeenAddedToLibrary": "{0} ha sigut afegit a la teva biblioteca",
+    "UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1} a {2}",
+    "UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1} a {2}",
+    "ValueHasBeenAddedToLibrary": "S'ha afegit {0} a la teva biblioteca",
     "ValueSpecialEpisodeName": "Especial - {0}",
     "VersionNumber": "Versió {0}",
     "TaskDownloadMissingSubtitlesDescription": "Cerca a internet els subtítols que faltin a partir de la configuració de metadades.",
     "TaskDownloadMissingSubtitles": "Descarrega els subtítols que faltin",
-    "TaskRefreshChannelsDescription": "Actualitza la informació dels canals d'internet.",
+    "TaskRefreshChannelsDescription": "Actualitza la informació dels canals per internet.",
     "TaskRefreshChannels": "Actualitza els canals",
     "TaskCleanTranscodeDescription": "Elimina els arxius de transcodificacions que tinguin més d'un dia.",
     "TaskCleanTranscode": "Neteja les transcodificacions",
-    "TaskUpdatePluginsDescription": "Actualitza els connectors que estan configurats per a actualitzar-se automàticament.",
-    "TaskUpdatePlugins": "Actualitza els connectors",
-    "TaskRefreshPeopleDescription": "Actualitza les metadades dels actors i directors de la teva mediateca.",
+    "TaskUpdatePluginsDescription": "Actualitza els complements que estan configurats per a actualitzar-se automàticament.",
+    "TaskUpdatePlugins": "Actualitza els complements",
+    "TaskRefreshPeopleDescription": "Actualitza les metadades dels actors i directors de la teva biblioteca de mitjans.",
     "TaskRefreshPeople": "Actualitza les persones",
     "TaskCleanLogsDescription": "Esborra els logs que tinguin més de {0} dies.",
     "TaskCleanLogs": "Neteja els registres",
-    "TaskRefreshLibraryDescription": "Escaneja la mediateca buscant fitxers nous i refresca les metadades.",
+    "TaskRefreshLibraryDescription": "Escaneja la biblioteca de mitjans buscant fitxers nous i refresca les metadades.",
     "TaskRefreshLibrary": "Escaneja la biblioteca de mitjans",
     "TaskRefreshChapterImagesDescription": "Crea les miniatures dels vídeos que tinguin capítols.",
     "TaskRefreshChapterImages": "Extreure les imatges dels capítols",
-    "TaskCleanCacheDescription": "Elimina els arxius temporals que ja no són necessaris per al servidor.",
-    "TaskCleanCache": "Elimina arxius temporals",
-    "TasksChannelsCategory": "Canals d'internet",
-    "TasksApplicationCategory": "Aplicació",
+    "TaskCleanCacheDescription": "Elimina la memòria cau no necessària per al servidor.",
+    "TaskCleanCache": "Elimina la memòria cau",
+    "TasksChannelsCategory": "Canals per internet",
+    "TasksApplicationCategory": "Aplicatiu",
     "TasksLibraryCategory": "Biblioteca",
     "TasksMaintenanceCategory": "Manteniment",
-    "TaskCleanActivityLogDescription": "Eliminat entrades del registre d'activitats mes antigues que l'antiguitat configurada.",
+    "TaskCleanActivityLogDescription": "Eliminades les entrades del registre d'activitats més antigues que l'antiguitat configurada.",
     "TaskCleanActivityLog": "Buidar el registre d'activitat",
     "Undefined": "Indefinit",
     "Forced": "Forçat",
@@ -128,11 +128,11 @@
     "TaskRefreshTrickplayImages": "Generar miniatures de línia de temps",
     "TaskRefreshTrickplayImagesDescription": "Crear miniatures de línia de temps per vídeos en les biblioteques habilitades.",
     "TaskCleanCollectionsAndPlaylistsDescription": "Esborra elements de col·leccions i llistes de reproducció que ja no existeixen.",
-    "TaskCleanCollectionsAndPlaylists": "Neteja col·leccions i llistes de reproducció",
-    "TaskAudioNormalization": "Normalització d'Àudio",
-    "TaskAudioNormalizationDescription": "Escaneja arxius per dades de normalització d'àudio.",
-    "TaskDownloadMissingLyricsDescription": "Baixar lletres de les cançons",
-    "TaskDownloadMissingLyrics": "Baixar lletres que falten",
+    "TaskCleanCollectionsAndPlaylists": "Neteja les col·leccions i llistes de reproducció",
+    "TaskAudioNormalization": "Estabilització d'Àudio",
+    "TaskAudioNormalizationDescription": "Escaneja arxius per dades d'estabilització d'àudio.",
+    "TaskDownloadMissingLyricsDescription": "Baixar les lletres de les cançons",
+    "TaskDownloadMissingLyrics": "Baixar les lletres que falten",
     "TaskExtractMediaSegments": "Escaneig de segments multimèdia",
     "TaskExtractMediaSegmentsDescription": "Extreu o obté segments multimèdia usant els connectors MediaSegment activats.",
     "TaskMoveTrickplayImages": "Migra la ubicació de la imatge de Trickplay",

+ 40 - 30
Emby.Server.Implementations/Localization/Core/eu.json

@@ -19,25 +19,25 @@
     "Artists": "Artistak",
     "Albums": "Albumak",
     "TaskOptimizeDatabase": "Datu basea optimizatu",
-    "TaskDownloadMissingSubtitlesDescription": "Metadataren konfigurazioan oinarrituta falta diren azpitituluak bilatzen ditu interneten.",
+    "TaskDownloadMissingSubtitlesDescription": "Falta diren azpitituluak bilatzen ditu interneten metadatuen konfigurazioaren arabera.",
     "TaskDownloadMissingSubtitles": "Falta diren azpitituluak deskargatu",
     "TaskRefreshChannelsDescription": "Internet kanalen informazioa eguneratu.",
     "TaskRefreshChannels": "Kanalak eguneratu",
-    "TaskCleanTranscodeDescription": "Egun bat baino zaharragoak diren transcode fitxategiak ezabatzen ditu.",
-    "TaskCleanTranscode": "Transcode direktorioa garbitu",
-    "TaskUpdatePluginsDescription": "Automatikoki eguneratzeko konfiguratutako pluginen eguneraketak deskargatu eta instalatzen ditu.",
+    "TaskCleanTranscodeDescription": "Egun bat baino zaharragoak diren transkodifikazio fitxategiak ezabatzen ditu.",
+    "TaskCleanTranscode": "Transkodifikazio direktorioa garbitu",
+    "TaskUpdatePluginsDescription": "Automatikoki deskargatu eta instalatu eguneraketak konfiguratutako pluginetarako.",
     "TaskUpdatePlugins": "Pluginak eguneratu",
-    "TaskRefreshPeopleDescription": "Zure liburutegiko aktore eta zuzendarien metadata eguneratzen du.",
+    "TaskRefreshPeopleDescription": "Zure liburutegiko aktore eta zuzendarien metadatuak eguneratzen ditu.",
     "TaskRefreshPeople": "Jendea eguneratu",
     "TaskCleanLogsDescription": "{0} egun baino zaharragoak diren log fitxategiak ezabatzen ditu.",
     "TaskCleanLogs": "Log direktorioa garbitu",
-    "TaskRefreshLibraryDescription": "Zure multimedia liburutegia eskaneatzen du fitxategi berriak eta metadatak eguneratzeko.",
-    "TaskRefreshLibrary": "Multimedia Liburutegia eskaneatu",
+    "TaskRefreshLibraryDescription": "Zure multimedia liburutegia eskaneatzen du fitxategi berriak eta metadatuak eguneratzeko.",
+    "TaskRefreshLibrary": "Multimedia liburutegia eskaneatu",
     "TaskRefreshChapterImagesDescription": "Kapituluak dituzten bideoen miniaturak sortzen ditu.",
     "TaskRefreshChapterImages": "Kapituluen irudiak erauzi",
     "TaskCleanCacheDescription": "Sistemak behar ez dituen cache fitxategiak ezabatzen ditu.",
-    "TaskCleanCache": "Cache Directorioa garbitu",
-    "TaskCleanActivityLogDescription": "Konfiguratuta data baino zaharragoak diren log-ak ezabatu.",
+    "TaskCleanCache": "Cache direktorioa garbitu",
+    "TaskCleanActivityLogDescription": "Konfiguratutako baino zaharragoak diren jarduera-log sarrerak ezabatzen ditu.",
     "TaskCleanActivityLog": "Erabilera Log-a garbitu",
     "TasksChannelsCategory": "Internet Kanalak",
     "TasksApplicationCategory": "Aplikazioa",
@@ -45,22 +45,22 @@
     "TasksMaintenanceCategory": "Mantenua",
     "VersionNumber": "Bertsioa {0}",
     "ValueHasBeenAddedToLibrary": "{0} zure multimedia liburutegian gehitu da",
-    "UserStoppedPlayingItemWithValues": "{0}-ek {1} ikusteaz bukatu du {2}-(a)n",
-    "UserStartedPlayingItemWithValues": "{0} {1} ikusten ari da {2}-(a)n",
-    "UserPolicyUpdatedWithName": "{0} Erabiltzailearen politikak aldatu dira",
-    "UserPasswordChangedWithName": "{0} Erabiltzailearen pasahitza aldatu da",
-    "UserOnlineFromDevice": "{0} online dago {1}-tik",
-    "UserOfflineFromDevice": "{0} {1}-tik deskonektatu da",
-    "UserLockedOutWithName": "{0} Erabiltzailea blokeatu da",
-    "UserDownloadingItemWithValues": "{1} {0}-tik deskargatzen",
+    "UserStoppedPlayingItemWithValues": "{0} {1} ikusten bukatu du {2}-(e)n",
+    "UserStartedPlayingItemWithValues": "{0} {1} ikusten ari da {2}-(e)n",
+    "UserPolicyUpdatedWithName": "{0} erabiltzailearen politikak aldatu dira",
+    "UserPasswordChangedWithName": "{0} erabiltzailearen pasahitza aldatu da",
+    "UserOnlineFromDevice": "{0} online dago {1}-(e)tik",
+    "UserOfflineFromDevice": "{0} {1}-(e)tik deskonektatu da",
+    "UserLockedOutWithName": "{0} erabiltzailea blokeatu da",
+    "UserDownloadingItemWithValues": "{0} {1} deskargatzen ari da",
     "UserDeletedWithName": "{0} Erabiltzailea ezabatu da",
     "UserCreatedWithName": "{0} Erabiltzailea sortu da",
     "User": "Erabiltzailea",
     "Undefined": "Ezezaguna",
-    "TvShows": "TB showak",
+    "TvShows": "TB serieak",
     "System": "Sistema",
-    "SubtitleDownloadFailureFromForItem": "{1}-en azpitutuluak {0} deskargatzean huts egin du",
-    "StartupEmbyServerIsLoading": "Jellyfin zerbitzaria kargatzen. Saiatu berriro beranduxeago.",
+    "SubtitleDownloadFailureFromForItem": "{1}-en azpitutuluak {0}-tik deskargatzeak huts egin du",
+    "StartupEmbyServerIsLoading": "Jellyfin zerbitzaria kargatzen. Saiatu berriro beranduago.",
     "ServerNameNeedsToBeRestarted": "{0} berrabiarazi behar da",
     "ScheduledTaskStartedWithName": "{0} hasi da",
     "ScheduledTaskFailedWithName": "{0} huts egin du",
@@ -89,26 +89,26 @@
     "NameSeasonNumber": "{0} Denboraldia",
     "NameInstallFailed": "{0} instalazioak huts egin du",
     "Music": "Musika",
-    "MixedContent": "Denetariko edukia",
+    "MixedContent": "Eduki mistoa",
     "MessageServerConfigurationUpdated": "Zerbitzariaren konfigurazioa eguneratu da",
-    "MessageNamedServerConfigurationUpdatedWithValue": "Zerbitzariaren konfigurazio {0} atala eguneratu da",
+    "MessageNamedServerConfigurationUpdatedWithValue": "Zerbitzariaren {0} konfigurazio atala eguneratu da",
     "MessageApplicationUpdatedTo": "Jellyfin zerbitzaria {0}-ra eguneratu da",
     "MessageApplicationUpdated": "Jellyfin zerbitzaria eguneratu da",
     "Latest": "Azkena",
-    "LabelRunningTimeValue": "Denbora martxan: {0}",
+    "LabelRunningTimeValue": "Iraupena: {0}",
     "LabelIpAddressValue": "IP helbidea: {0}",
-    "ItemRemovedWithName": "{0} liburutegitik ezabatu da",
+    "ItemRemovedWithName": "{0} liburutegitik kendu da",
     "ItemAddedWithName": "{0} liburutegira gehitu da",
     "HomeVideos": "Etxeko bideoak",
-    "HeaderNextUp": "Nobedadeak",
+    "HeaderNextUp": "Hurrengoa",
     "HeaderLiveTV": "Zuzeneko TB",
     "HeaderFavoriteSongs": "Gogoko abestiak",
-    "HeaderFavoriteShows": "Gogoko showak",
+    "HeaderFavoriteShows": "Gogoko serieak",
     "HeaderFavoriteEpisodes": "Gogoko atalak",
     "HeaderFavoriteArtists": "Gogoko artistak",
     "HeaderFavoriteAlbums": "Gogoko albumak",
     "Forced": "Behartuta",
-    "FailedLoginAttemptWithUserName": "Login egiten akatsa, saiatu hemen {0}",
+    "FailedLoginAttemptWithUserName": "{0}-tik saioa hasteak huts egin du",
     "External": "Kanpokoa",
     "DeviceOnlineWithName": "{0} konektatu da",
     "DeviceOfflineWithName": "{0} deskonektatu da",
@@ -117,13 +117,23 @@
     "AuthenticationSucceededWithUserName": "{0} ongi autentifikatu da",
     "Application": "Aplikazioa",
     "AppDeviceValues": "App: {0}, Gailua: {1}",
-    "HearingImpaired": "Entzunaldia aldatua",
+    "HearingImpaired": "Entzumen urritasuna",
     "ProviderValue": "Hornitzailea: {0}",
     "TaskKeyframeExtractorDescription": "Bideo fitxategietako fotograma gakoak ateratzen ditu HLS erreprodukzio-zerrenda zehatzagoak sortzeko. Zeregin honek denbora asko iraun dezake.",
     "HeaderRecordingGroups": "Grabaketa taldeak",
     "Inherit": "Oinordetu",
     "TaskOptimizeDatabaseDescription": "Datu-basea trinkotu eta bertatik espazioa askatzen du. Liburutegia eskaneatu ondoren edo datu-basean aldaketak egin ondoren ataza hau exekutatzeak errendimendua hobetu lezake.",
     "TaskKeyframeExtractor": "Fotograma gakoen erauzgailua",
-    "TaskRefreshTrickplayImages": "\"Trickplay Irudiak Sortu",
-    "TaskRefreshTrickplayImagesDescription": "Bideoentzako trickplay aurrebistak sortzen ditu gaitutako liburutegietan."
+    "TaskRefreshTrickplayImages": "Trickplay irudiak sortu",
+    "TaskRefreshTrickplayImagesDescription": "Bideoentzako trickplay aurrebistak sortzen ditu gaitutako liburutegietan.",
+    "TaskAudioNormalization": "Audio normalizazioa",
+    "TaskDownloadMissingLyrics": "Deskargatu falta diren letrak",
+    "TaskDownloadMissingLyricsDescription": "Deskargatu abestientzako letrak",
+    "TaskExtractMediaSegments": "Multimedia segmentuen eskaneoa",
+    "TaskCleanCollectionsAndPlaylistsDescription": "Jada existitzen ez diren bildumak eta erreprodukzio-zerrendak kentzen ditu.",
+    "TaskCleanCollectionsAndPlaylists": "Garbitu bildumak eta erreprodukzio-zerrendak",
+    "TaskExtractMediaSegmentsDescription": "Media segmentuak atera edo lortzen ditu MediaSegment gaituta duten pluginetik.",
+    "TaskMoveTrickplayImages": "Aldatu Trickplay irudien kokalekua",
+    "TaskMoveTrickplayImagesDescription": "Lehendik dauden trickplay fitxategiak liburutegiaren ezarpenen arabera mugitzen dira.",
+    "TaskAudioNormalizationDescription": "Audio normalizazio datuak lortzeko fitxategiak eskaneatzen ditu."
 }

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

@@ -1 +1,3 @@
-{}
+{
+    "Books": "liv"
+}

+ 139 - 0
Emby.Server.Implementations/Localization/Core/lb.json

@@ -0,0 +1,139 @@
+{
+    "Albums": "Alben",
+    "Application": "Applikatioun",
+    "Artists": "Kënschtler",
+    "Books": "Bicher",
+    "Channels": "Kanäl",
+    "Collections": "Kollektiounen",
+    "Default": "Standard",
+    "ChapterNameValue": "Kapitel {0}",
+    "DeviceOnlineWithName": "{0} ass Online",
+    "DeviceOfflineWithName": "{0} ass Offline",
+    "External": "Extern",
+    "Favorites": "Favoritten",
+    "Folders": "Dossieren",
+    "Forced": "Forcéiert",
+    "HeaderAlbumArtists": "Album Kënschtler",
+    "HeaderFavoriteAlbums": "Léifsten Alben",
+    "HeaderFavoriteArtists": "Léifsten Kënschtler",
+    "HeaderFavoriteEpisodes": "Léifsten Episoden",
+    "HeaderFavoriteShows": "Léifsten Shows",
+    "HeaderFavoriteSongs": "Léifsten Lidder",
+    "Genres": "Generen",
+    "HeaderContinueWatching": "Weider kucken",
+    "Inherit": "Iwwerhuelen",
+    "HeaderNextUp": "Als Nächst",
+    "HeaderRecordingGroups": "Opname Gruppen",
+    "HearingImpaired": "Daaf",
+    "HomeVideos": "Amateur Videoen",
+    "ItemRemovedWithName": "Element ewech geholl: {0}",
+    "LabelIpAddressValue": "IP Adress: {0}",
+    "LabelRunningTimeValue": "Lafzäit: {0}",
+    "Latest": "Dat Aktuellst",
+    "MessageApplicationUpdatedTo": "Jellyfin Server aktualiséiert op {0}",
+    "MessageNamedServerConfigurationUpdatedWithValue": "Server Konfiguratiounssektioun {0} aktualiséiert",
+    "MessageServerConfigurationUpdated": "Server Konfiguratioun aktualiséiert",
+    "Movies": "Filmer",
+    "Music": "Musek",
+    "NameInstallFailed": "{0} Installatioun net gelongen",
+    "NameSeasonNumber": "Staffel {0}",
+    "NameSeasonUnknown": "Staffel Onbekannt",
+    "MusicVideos": "Museksvideoen",
+    "NotificationOptionApplicationUpdateAvailable": "Applikatiouns Update verfügbar",
+    "NotificationOptionApplicationUpdateInstalled": "Applikatiouns Update nët Installéiert",
+    "NotificationOptionAudioPlayback": "Audio ofspillen gestart",
+    "NotificationOptionAudioPlaybackStopped": "Audio ofspillen gestoppt",
+    "NotificationOptionCameraImageUploaded": "Kamera Bild eropgelueden",
+    "NotificationOptionInstallationFailed": "Installatioun net gelongen",
+    "NotificationOptionNewLibraryContent": "Neien Bibliothéik Inhalt",
+    "NotificationOptionPluginError": "Plugin Feeler",
+    "NotificationOptionPluginInstalled": "Plugin installéiert",
+    "NotificationOptionPluginUninstalled": "Plugin desinstalléiert",
+    "NotificationOptionPluginUpdateInstalled": "Plugin Update installéiert",
+    "Photos": "Fotoen",
+    "NotificationOptionTaskFailed": "Aufgab net gelongen",
+    "NotificationOptionUserLockedOut": "Benotzer Gesperrt",
+    "NotificationOptionVideoPlaybackStopped": "Video ofspillen gestoppt",
+    "NotificationOptionVideoPlayback": "Video ofspillen gestartet",
+    "Plugin": "Plugin",
+    "PluginUninstalledWithName": "{0} desinstalléiert",
+    "PluginUpdatedWithName": "{0} aktualiséiert",
+    "ProviderValue": "Provider: {0}",
+    "ScheduledTaskFailedWithName": "Aufgab: {0} net gelongen",
+    "Playlists": "Playlëschten",
+    "Shows": "Shows",
+    "Songs": "Lidder",
+    "ServerNameNeedsToBeRestarted": "{0} muss nei gestart ginn",
+    "StartupEmbyServerIsLoading": "Jellyfin Server luedt. Probéier méi spéit nach eng Kéier.",
+    "Sync": "Synchroniséieren",
+    "System": "System",
+    "User": "Benotzer",
+    "TvShows": "TV Shows",
+    "Undefined": "Net definéiert",
+    "UserCreatedWithName": "Benotzer {0} erstellt",
+    "UserDownloadingItemWithValues": "{0} luet {1} erof",
+    "UserOfflineFromDevice": "{0} Benotzer Offline um Gerät {1}",
+    "UserLockedOutWithName": "Benotzer {0} gesperrt",
+    "UserOnlineFromDevice": "{0} Benotzer Online um Gerät {1}",
+    "UserPasswordChangedWithName": "Benotzer Passwuert geännert fir {0}",
+    "UserPolicyUpdatedWithName": "Benotzer Politik aktualiséiert fir: {0}",
+    "UserStartedPlayingItemWithValues": "{0} spillt {1} op {2} oof",
+    "ValueHasBeenAddedToLibrary": "{0} der Bibliothéik bäigefüügt",
+    "VersionNumber": "Versioun {0}",
+    "TasksMaintenanceCategory": "Ënnerhalt",
+    "TasksLibraryCategory": "Bibliothéik",
+    "ValueSpecialEpisodeName": "Spezial-Episodenumm",
+    "TasksChannelsCategory": "Internet Kanäl",
+    "TaskCleanActivityLog": "Aktivitéits Log botzen",
+    "TaskCleanActivityLogDescription": "Läscht Aktivitéitslogs méi al wéi konfiguréiert.",
+    "TaskCleanCache": "Aufgab Cache Botzen",
+    "TaskRefreshChapterImages": "Kapitel Biller erstellen",
+    "TaskRefreshChapterImagesDescription": "Erstellt Miniaturbiller fir Videoen, déi Kapitelen hunn.",
+    "TaskAudioNormalization": "Audio Normaliséierung",
+    "TaskRefreshLibrary": "Bibliothéik aktualiséieren",
+    "TaskRefreshLibraryDescription": "Scannt deng Mediebibliothéik no neien Dateien a frëscht d’Metadata op.",
+    "TaskCleanLogs": "Log Dateien botzen",
+    "TaskRefreshPeople": "Persounen aktualiséieren",
+    "TaskRefreshPeopleDescription": "Aktualiséiert Metadata fir Schauspiller a Regisseuren an denger Mediebibliothéik.",
+    "TaskRefreshTrickplayImagesDescription": "Erstellt Trickplay-Viraussiichten fir Videoen an aktivéierte Bibliothéiken.",
+    "TaskCleanTranscode": "Transkodéieren botzen",
+    "TaskCleanTranscodeDescription": "Läscht Transkodéierungsdateien, déi méi al wéi een Dag sinn.",
+    "TaskRefreshChannels": "Kanäl aktualiséieren",
+    "TaskDownloadMissingLyrics": "Fehlend Liddertexter eroflueden",
+    "TaskDownloadMissingLyricsDescription": "Lued Liddertexter fir Lidder erof",
+    "TaskDownloadMissingSubtitles": "Fehlend Ënnertitelen eroflueden",
+    "TaskOptimizeDatabase": "Datebank optiméieren",
+    "TaskKeyframeExtractor": "Schlësselbild Extrakter",
+    "TaskCleanCollectionsAndPlaylists": "Sammlungen a Playlisten botzen",
+    "TaskCleanCollectionsAndPlaylistsDescription": "Ewechhuele vun Elementer aus Sammlungen a Playlisten, déi net méi existéieren.",
+    "TaskExtractMediaSegments": "Mediesegment-Scan",
+    "NewVersionIsAvailable": "Nei Versioun fir Jellyfin Server ass verfügbar.",
+    "CameraImageUploadedFrom": "En neit Kamera Bild gouf vu {0} eropgelueden",
+    "PluginInstalledWithName": "{0} installéiert",
+    "TaskMoveTrickplayImagesDescription": "Verschëfft existent Trickplay-Dateien no de Bibliothéik-Astellungen.",
+    "AppDeviceValues": "App: {0}, Geräter: {1}",
+    "FailedLoginAttemptWithUserName": "Net Gelongen Umeldung {0}",
+    "HeaderLiveTV": "LiveTV",
+    "ItemAddedWithName": "Element derbäi gesat: {0}",
+    "NotificationOptionServerRestartRequired": "Server Restart Erfuerderlech",
+    "ScheduledTaskStartedWithName": "Aufgab: {0} gestart",
+    "AuthenticationSucceededWithUserName": "{0} Authentifikatioun gelongen",
+    "MixedContent": "Gemëschten Inhalt",
+    "MessageApplicationUpdated": "Jellyfin Server Aktualiséiert",
+    "SubtitleDownloadFailureFromForItem": "Ënnertitel Download Feeler vun {0} fir {1}",
+    "TaskCleanLogsDescription": "Läscht Log-Dateien, déi méi al wéi {0} Deeg sinn.",
+    "TaskUpdatePlugins": "Plugins aktualiséieren",
+    "UserDeletedWithName": "Benotzer {0} geläscht",
+    "TasksApplicationCategory": "Applikatioun",
+    "TaskCleanCacheDescription": "Läscht Cache-Dateien, déi net méi vum System gebraucht ginn.",
+    "UserStoppedPlayingItemWithValues": "{0} ass mat {1} op {2} fäerdeg",
+    "TaskAudioNormalizationDescription": "Scannt Dateien no Donnéeën fir d’Audio-Normaliséierung.",
+    "TaskRefreshTrickplayImages": "Trickplay-Biller generéieren",
+    "TaskDownloadMissingSubtitlesDescription": "Sicht am Internet no fehlenden Ënnertitelen op Basis vun der Metadata-Konfiguratioun.",
+    "TaskMoveTrickplayImages": "Trickplay-Biller-Plaz migréieren",
+    "TaskUpdatePluginsDescription": "Lued Aktualiséierungen erof a installéiert se fir Plugins, déi fir automatesch Updates konfiguréiert sinn.",
+    "TaskKeyframeExtractorDescription": "Extrahéiert Schlësselbiller aus Videodateien, fir méi präzis HLS-Playlisten ze erstellen. Dës Aufgab kann eng längere Zäit daueren.",
+    "TaskRefreshChannelsDescription": "Aktualiséiert Informatiounen iwwer Internetkanäl.",
+    "TaskExtractMediaSegmentsDescription": "Extrahéiert oder kritt Mediesegmenter aus Plugins, déi MediaSegment ënnerstëtzen.",
+    "TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren."
+}

+ 81 - 17
Emby.Server.Implementations/Session/WebSocketController.cs

@@ -21,6 +21,7 @@ namespace Emby.Server.Implementations.Session
         private readonly SessionInfo _session;
 
         private readonly List<IWebSocketConnection> _sockets;
+        private readonly ReaderWriterLockSlim _socketsLock;
         private bool _disposed = false;
 
         public WebSocketController(
@@ -31,10 +32,26 @@ namespace Emby.Server.Implementations.Session
             _logger = logger;
             _session = session;
             _sessionManager = sessionManager;
-            _sockets = new List<IWebSocketConnection>();
+            _sockets = new();
+            _socketsLock = new();
         }
 
-        private bool HasOpenSockets => GetActiveSockets().Any();
+        private bool HasOpenSockets
+        {
+            get
+            {
+                ObjectDisposedException.ThrowIf(_disposed, this);
+                try
+                {
+                    _socketsLock.EnterReadLock();
+                    return _sockets.Any(i => i.State == WebSocketState.Open);
+                }
+                finally
+                {
+                    _socketsLock.ExitReadLock();
+                }
+            }
+        }
 
         /// <inheritdoc />
         public bool SupportsMediaControl => HasOpenSockets;
@@ -42,23 +59,38 @@ namespace Emby.Server.Implementations.Session
         /// <inheritdoc />
         public bool IsSessionActive => HasOpenSockets;
 
-        private IEnumerable<IWebSocketConnection> GetActiveSockets()
-            => _sockets.Where(i => i.State == WebSocketState.Open);
-
         public void AddWebSocket(IWebSocketConnection connection)
         {
             _logger.LogDebug("Adding websocket to session {Session}", _session.Id);
-            _sockets.Add(connection);
-
-            connection.Closed += OnConnectionClosed;
+            ObjectDisposedException.ThrowIf(_disposed, this);
+            try
+            {
+                _socketsLock.EnterWriteLock();
+                _sockets.Add(connection);
+                connection.Closed += OnConnectionClosed;
+            }
+            finally
+            {
+                _socketsLock.ExitWriteLock();
+            }
         }
 
         private async void OnConnectionClosed(object? sender, EventArgs e)
         {
             var connection = sender as IWebSocketConnection ?? throw new ArgumentException($"{nameof(sender)} is not of type {nameof(IWebSocketConnection)}", nameof(sender));
             _logger.LogDebug("Removing websocket from session {Session}", _session.Id);
-            _sockets.Remove(connection);
-            connection.Closed -= OnConnectionClosed;
+            ObjectDisposedException.ThrowIf(_disposed, this);
+            try
+            {
+                _socketsLock.EnterWriteLock();
+                _sockets.Remove(connection);
+                connection.Closed -= OnConnectionClosed;
+            }
+            finally
+            {
+                _socketsLock.ExitWriteLock();
+            }
+
             await _sessionManager.CloseIfNeededAsync(_session).ConfigureAwait(false);
         }
 
@@ -69,7 +101,17 @@ namespace Emby.Server.Implementations.Session
             T data,
             CancellationToken cancellationToken)
         {
-            var socket = GetActiveSockets().MaxBy(i => i.LastActivityDate);
+            ObjectDisposedException.ThrowIf(_disposed, this);
+            IWebSocketConnection? socket;
+            try
+            {
+                _socketsLock.EnterReadLock();
+                socket = _sockets.Where(i => i.State == WebSocketState.Open).MaxBy(i => i.LastActivityDate);
+            }
+            finally
+            {
+                _socketsLock.ExitReadLock();
+            }
 
             if (socket is null)
             {
@@ -94,12 +136,23 @@ namespace Emby.Server.Implementations.Session
                 return;
             }
 
-            foreach (var socket in _sockets)
+            try
+            {
+                _socketsLock.EnterWriteLock();
+                foreach (var socket in _sockets)
+                {
+                    socket.Closed -= OnConnectionClosed;
+                    socket.Dispose();
+                }
+
+                _sockets.Clear();
+            }
+            finally
             {
-                socket.Closed -= OnConnectionClosed;
-                socket.Dispose();
+                _socketsLock.ExitWriteLock();
             }
 
+            _socketsLock.Dispose();
             _disposed = true;
         }
 
@@ -110,12 +163,23 @@ namespace Emby.Server.Implementations.Session
                 return;
             }
 
-            foreach (var socket in _sockets)
+            try
+            {
+                _socketsLock.EnterWriteLock();
+                foreach (var socket in _sockets)
+                {
+                    socket.Closed -= OnConnectionClosed;
+                    await socket.DisposeAsync().ConfigureAwait(false);
+                }
+
+                _sockets.Clear();
+            }
+            finally
             {
-                socket.Closed -= OnConnectionClosed;
-                await socket.DisposeAsync().ConfigureAwait(false);
+                _socketsLock.ExitWriteLock();
             }
 
+            _socketsLock.Dispose();
             _disposed = true;
         }
     }

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

@@ -91,31 +91,31 @@ public class ArtistsController : BaseJellyfinApiController
         [FromQuery] int? limit,
         [FromQuery] string? searchTerm,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
         [FromQuery] bool? isFavorite,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] 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, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] string? person,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds,
         [FromQuery] Guid? userId,
         [FromQuery] string? nameStartsWithOrGreater,
         [FromQuery] string? nameStartsWith,
         [FromQuery] string? nameLessThan,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool enableTotalRecordCount = true)
     {
@@ -295,31 +295,31 @@ public class ArtistsController : BaseJellyfinApiController
         [FromQuery] int? limit,
         [FromQuery] string? searchTerm,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
         [FromQuery] bool? isFavorite,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] 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, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] string? person,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds,
         [FromQuery] Guid? userId,
         [FromQuery] string? nameStartsWithOrGreater,
         [FromQuery] string? nameStartsWith,
         [FromQuery] string? nameLessThan,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool enableTotalRecordCount = true)
     {

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

@@ -121,10 +121,10 @@ public class ChannelsController : BaseJellyfinApiController
         [FromQuery] Guid? userId,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()
@@ -197,9 +197,9 @@ public class ChannelsController : BaseJellyfinApiController
         [FromQuery] Guid? userId,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] channelIds)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()

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

@@ -50,7 +50,7 @@ public class CollectionController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public async Task<ActionResult<CollectionCreationResult>> CreateCollection(
         [FromQuery] string? name,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] ids,
         [FromQuery] Guid? parentId,
         [FromQuery] bool isLocked = false)
     {
@@ -86,7 +86,7 @@ public class CollectionController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     public async Task<ActionResult> AddToCollection(
         [FromRoute, Required] Guid collectionId,
-        [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+        [FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids)
     {
         await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
         return NoContent();
@@ -103,7 +103,7 @@ public class CollectionController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     public async Task<ActionResult> RemoveFromCollection(
         [FromRoute, Required] Guid collectionId,
-        [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+        [FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids)
     {
         await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false);
         return NoContent();

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

@@ -50,8 +50,8 @@ public class FilterController : BaseJellyfinApiController
     public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
         [FromQuery] Guid? userId,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()
@@ -137,7 +137,7 @@ public class FilterController : BaseJellyfinApiController
     public ActionResult<QueryFilters> GetQueryFilters(
         [FromQuery] Guid? userId,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
         [FromQuery] bool? isAiring,
         [FromQuery] bool? isMovie,
         [FromQuery] bool? isSports,

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

@@ -76,18 +76,18 @@ public class GenresController : BaseJellyfinApiController
         [FromQuery] int? limit,
         [FromQuery] string? searchTerm,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
         [FromQuery] bool? isFavorite,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] Guid? userId,
         [FromQuery] string? nameStartsWithOrGreater,
         [FromQuery] string? nameStartsWith,
         [FromQuery] string? nameLessThan,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool enableTotalRecordCount = true)
     {

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

@@ -73,11 +73,11 @@ public class InstantMixController : BaseJellyfinApiController
         [FromRoute, Required] Guid itemId,
         [FromQuery] Guid? userId,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableImages,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()
@@ -117,11 +117,11 @@ public class InstantMixController : BaseJellyfinApiController
         [FromRoute, Required] Guid itemId,
         [FromQuery] Guid? userId,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableImages,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()
@@ -161,11 +161,11 @@ public class InstantMixController : BaseJellyfinApiController
         [FromRoute, Required] Guid itemId,
         [FromQuery] Guid? userId,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableImages,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()
@@ -203,11 +203,11 @@ public class InstantMixController : BaseJellyfinApiController
         [FromRoute, Required] string name,
         [FromQuery] Guid? userId,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableImages,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()
@@ -241,11 +241,11 @@ public class InstantMixController : BaseJellyfinApiController
         [FromRoute, Required] Guid itemId,
         [FromQuery] Guid? userId,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableImages,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()
@@ -285,11 +285,11 @@ public class InstantMixController : BaseJellyfinApiController
         [FromRoute, Required] Guid itemId,
         [FromQuery] Guid? userId,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableImages,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()
@@ -330,11 +330,11 @@ public class InstantMixController : BaseJellyfinApiController
         [FromQuery, Required] Guid id,
         [FromQuery] Guid? userId,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableImages,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
     {
         return GetInstantMixFromArtists(
             id,
@@ -368,11 +368,11 @@ public class InstantMixController : BaseJellyfinApiController
         [FromQuery, Required] Guid id,
         [FromQuery] Guid? userId,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableImages,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()

+ 72 - 72
Jellyfin.Api/Controllers/ItemsController.cs

@@ -172,8 +172,8 @@ public class ItemsController : BaseJellyfinApiController
         [FromQuery] bool? hasParentalRating,
         [FromQuery] bool? isHd,
         [FromQuery] bool? is4K,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] locationTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] excludeLocationTypes,
         [FromQuery] bool? isMissing,
         [FromQuery] bool? isUnaired,
         [FromQuery] double? minCommunityRating,
@@ -191,42 +191,42 @@ public class ItemsController : BaseJellyfinApiController
         [FromQuery] bool? isNews,
         [FromQuery] bool? isKids,
         [FromQuery] bool? isSports,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeItemIds,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
         [FromQuery] bool? recursive,
         [FromQuery] string? searchTerm,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
         [FromQuery] bool? isFavorite,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] imageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
         [FromQuery] bool? isPlayed,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] string? person,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] artists,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] artistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] contributingArtistIds,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] albums,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] VideoType[] videoTypes,
         [FromQuery] string? minOfficialRating,
         [FromQuery] bool? isLocked,
         [FromQuery] bool? isPlaceHolder,
@@ -237,12 +237,12 @@ public class ItemsController : BaseJellyfinApiController
         [FromQuery] int? maxWidth,
         [FromQuery] int? maxHeight,
         [FromQuery] bool? is3D,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SeriesStatus[] seriesStatus,
         [FromQuery] string? nameStartsWithOrGreater,
         [FromQuery] string? nameStartsWith,
         [FromQuery] string? nameLessThan,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds,
         [FromQuery] bool enableTotalRecordCount = true,
         [FromQuery] bool? enableImages = true)
     {
@@ -639,8 +639,8 @@ public class ItemsController : BaseJellyfinApiController
         [FromQuery] bool? hasParentalRating,
         [FromQuery] bool? isHd,
         [FromQuery] bool? is4K,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] locationTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] excludeLocationTypes,
         [FromQuery] bool? isMissing,
         [FromQuery] bool? isUnaired,
         [FromQuery] double? minCommunityRating,
@@ -658,42 +658,42 @@ public class ItemsController : BaseJellyfinApiController
         [FromQuery] bool? isNews,
         [FromQuery] bool? isKids,
         [FromQuery] bool? isSports,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeItemIds,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
         [FromQuery] bool? recursive,
         [FromQuery] string? searchTerm,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
         [FromQuery] bool? isFavorite,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] imageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
         [FromQuery] bool? isPlayed,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] string? person,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] artists,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] artistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] contributingArtistIds,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] albums,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] VideoType[] videoTypes,
         [FromQuery] string? minOfficialRating,
         [FromQuery] bool? isLocked,
         [FromQuery] bool? isPlaceHolder,
@@ -704,12 +704,12 @@ public class ItemsController : BaseJellyfinApiController
         [FromQuery] int? maxWidth,
         [FromQuery] int? maxHeight,
         [FromQuery] bool? is3D,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SeriesStatus[] seriesStatus,
         [FromQuery] string? nameStartsWithOrGreater,
         [FromQuery] string? nameStartsWith,
         [FromQuery] string? nameLessThan,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds,
         [FromQuery] bool enableTotalRecordCount = true,
         [FromQuery] bool? enableImages = true)
         => GetItems(
@@ -828,13 +828,13 @@ public class ItemsController : BaseJellyfinApiController
         [FromQuery] int? limit,
         [FromQuery] string? searchTerm,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
         [FromQuery] bool enableTotalRecordCount = true,
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool excludeActiveSessions = false)
@@ -930,13 +930,13 @@ public class ItemsController : BaseJellyfinApiController
         [FromQuery] int? limit,
         [FromQuery] string? searchTerm,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
         [FromQuery] bool enableTotalRecordCount = true,
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool excludeActiveSessions = false)

+ 9 - 9
Jellyfin.Api/Controllers/LibraryController.cs

@@ -144,8 +144,8 @@ public class LibraryController : BaseJellyfinApiController
         [FromRoute, Required] Guid itemId,
         [FromQuery] Guid? userId,
         [FromQuery] bool inheritFromParent = false,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[]? sortBy = null,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[]? sortOrder = null)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()
@@ -218,8 +218,8 @@ public class LibraryController : BaseJellyfinApiController
         [FromRoute, Required] Guid itemId,
         [FromQuery] Guid? userId,
         [FromQuery] bool inheritFromParent = false,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[]? sortBy = null,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[]? sortOrder = null)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()
@@ -290,8 +290,8 @@ public class LibraryController : BaseJellyfinApiController
         [FromRoute, Required] Guid itemId,
         [FromQuery] Guid? userId,
         [FromQuery] bool inheritFromParent = false,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[]? sortBy = null,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[]? sortOrder = null)
     {
         var themeSongs = GetThemeSongs(
             itemId,
@@ -400,7 +400,7 @@ public class LibraryController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     [ProducesResponseType(StatusCodes.Status401Unauthorized)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
-    public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+    public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids)
     {
         var isApiKey = User.GetIsApiKey();
         var userId = User.GetUserId();
@@ -722,10 +722,10 @@ public class LibraryController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
         [FromRoute, Required] Guid itemId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds,
         [FromQuery] Guid? userId,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.IsNullOrEmpty()

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

@@ -77,7 +77,7 @@ public class LibraryStructureController : BaseJellyfinApiController
     public async Task<ActionResult> AddVirtualFolder(
         [FromQuery] string name,
         [FromQuery] CollectionTypeOptions? collectionType,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] paths,
         [FromBody] AddVirtualFolderDto? libraryOptionsDto,
         [FromQuery] bool refreshLibrary = false)
     {

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

@@ -159,10 +159,10 @@ public class LiveTvController : BaseJellyfinApiController
         [FromQuery] bool? isDisliked,
         [FromQuery] bool? enableImages,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableUserData,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
         [FromQuery] SortOrder? sortOrder,
         [FromQuery] bool enableFavoriteSorting = false,
         [FromQuery] bool addCurrentProgram = true)
@@ -283,8 +283,8 @@ public class LiveTvController : BaseJellyfinApiController
         [FromQuery] string? seriesTimerId,
         [FromQuery] bool? enableImages,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableUserData,
         [FromQuery] bool? isMovie,
         [FromQuery] bool? isSeries,
@@ -371,8 +371,8 @@ public class LiveTvController : BaseJellyfinApiController
         [FromQuery] string? seriesTimerId,
         [FromQuery] bool? enableImages,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableUserData,
         [FromQuery] bool enableTotalRecordCount = true)
     {
@@ -566,7 +566,7 @@ public class LiveTvController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     [Authorize(Policy = Policies.LiveTvAccess)]
     public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms(
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] channelIds,
         [FromQuery] Guid? userId,
         [FromQuery] DateTime? minStartDate,
         [FromQuery] bool? hasAired,
@@ -581,17 +581,17 @@ public class LiveTvController : BaseJellyfinApiController
         [FromQuery] bool? isSports,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds,
         [FromQuery] bool? enableImages,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] bool? enableUserData,
         [FromQuery] string? seriesTimerId,
         [FromQuery] Guid? librarySeriesId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool enableTotalRecordCount = true)
     {
         userId = RequestHelpers.GetUserId(User, userId);
@@ -730,9 +730,9 @@ public class LiveTvController : BaseJellyfinApiController
         [FromQuery] bool? isSports,
         [FromQuery] bool? enableImages,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableUserData,
         [FromQuery] bool enableTotalRecordCount = true)
     {

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

@@ -65,7 +65,7 @@ public class MoviesController : BaseJellyfinApiController
     public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
         [FromQuery] Guid? userId,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] int categoryLimit = 5,
         [FromQuery] int itemLimit = 8)
     {

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

@@ -76,18 +76,18 @@ public class MusicGenresController : BaseJellyfinApiController
         [FromQuery] int? limit,
         [FromQuery] string? searchTerm,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
         [FromQuery] bool? isFavorite,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] Guid? userId,
         [FromQuery] string? nameStartsWithOrGreater,
         [FromQuery] string? nameStartsWith,
         [FromQuery] string? nameLessThan,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool enableTotalRecordCount = true)
     {

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

@@ -67,14 +67,14 @@ public class PersonsController : BaseJellyfinApiController
     public ActionResult<QueryResult<BaseItemDto>> GetPersons(
         [FromQuery] int? limit,
         [FromQuery] string? searchTerm,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
         [FromQuery] bool? isFavorite,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] excludePersonTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes,
         [FromQuery] Guid? appearsInItemId,
         [FromQuery] Guid? userId,
         [FromQuery] bool? enableImages = true)

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

@@ -76,7 +76,7 @@ public class PlaylistsController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
         [FromQuery, ParameterObsolete] string? name,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList<Guid> ids,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder)), ParameterObsolete] IReadOnlyList<Guid> ids,
         [FromQuery, ParameterObsolete] Guid? userId,
         [FromQuery, ParameterObsolete] MediaType? mediaType,
         [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest)
@@ -370,7 +370,7 @@ public class PlaylistsController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     public async Task<ActionResult> AddItemToPlaylist(
         [FromRoute, Required] Guid playlistId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids,
         [FromQuery] Guid? userId)
     {
         userId = RequestHelpers.GetUserId(User, userId);
@@ -446,7 +446,7 @@ public class PlaylistsController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     public async Task<ActionResult> RemoveItemFromPlaylist(
         [FromRoute, Required] string playlistId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] entryIds)
     {
         var callingUserId = User.GetUserId();
 
@@ -493,11 +493,11 @@ public class PlaylistsController : BaseJellyfinApiController
         [FromQuery] Guid? userId,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? enableImages,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
     {
         var callingUserId = userId ?? User.GetUserId();
         var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);

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

@@ -84,9 +84,9 @@ public class SearchController : BaseJellyfinApiController
         [FromQuery] int? limit,
         [FromQuery] Guid? userId,
         [FromQuery, Required] string searchTerm,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
         [FromQuery] Guid? parentId,
         [FromQuery] bool? isMovie,
         [FromQuery] bool? isSeries,

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

@@ -122,7 +122,7 @@ public class SessionController : BaseJellyfinApiController
     public async Task<ActionResult> Play(
         [FromRoute, Required] string sessionId,
         [FromQuery, Required] PlayCommand playCommand,
-        [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds,
+        [FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] itemIds,
         [FromQuery] long? startPositionTicks,
         [FromQuery] string? mediaSourceId,
         [FromQuery] int? audioStreamIndex,
@@ -347,8 +347,8 @@ public class SessionController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     public async Task<ActionResult> PostCapabilities(
         [FromQuery] string? id,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] playableMediaTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] playableMediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] GeneralCommandType[] supportedCommands,
         [FromQuery] bool supportsMediaControl = false,
         [FromQuery] bool supportsPersistentIdentifier = true)
     {

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

@@ -73,13 +73,13 @@ public class StudiosController : BaseJellyfinApiController
         [FromQuery] int? limit,
         [FromQuery] string? searchTerm,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
         [FromQuery] bool? isFavorite,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] Guid? userId,
         [FromQuery] string? nameStartsWithOrGreater,
         [FromQuery] string? nameStartsWith,

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

@@ -59,8 +59,8 @@ public class SuggestionsController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
         [FromQuery] Guid? userId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaType,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaType,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] type,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
         [FromQuery] bool enableTotalRecordCount = false)
@@ -115,8 +115,8 @@ public class SuggestionsController : BaseJellyfinApiController
     [ApiExplorerSettings(IgnoreApi = true)]
     public ActionResult<QueryResult<BaseItemDto>> GetSuggestionsLegacy(
         [FromRoute, Required] Guid userId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaType,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaType,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] type,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
         [FromQuery] bool enableTotalRecordCount = false)

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

@@ -130,8 +130,8 @@ public class TrailersController : BaseJellyfinApiController
         [FromQuery] bool? hasParentalRating,
         [FromQuery] bool? isHd,
         [FromQuery] bool? is4K,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] locationTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] excludeLocationTypes,
         [FromQuery] bool? isMissing,
         [FromQuery] bool? isUnaired,
         [FromQuery] double? minCommunityRating,
@@ -149,41 +149,41 @@ public class TrailersController : BaseJellyfinApiController
         [FromQuery] bool? isNews,
         [FromQuery] bool? isKids,
         [FromQuery] bool? isSports,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeItemIds,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
         [FromQuery] bool? recursive,
         [FromQuery] string? searchTerm,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
         [FromQuery] bool? isFavorite,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] imageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
         [FromQuery] bool? isPlayed,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
-        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] string? person,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] artists,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] artistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] contributingArtistIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] albums,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] VideoType[] videoTypes,
         [FromQuery] string? minOfficialRating,
         [FromQuery] bool? isLocked,
         [FromQuery] bool? isPlaceHolder,
@@ -194,12 +194,12 @@ public class TrailersController : BaseJellyfinApiController
         [FromQuery] int? maxWidth,
         [FromQuery] int? maxHeight,
         [FromQuery] bool? is3D,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SeriesStatus[] seriesStatus,
         [FromQuery] string? nameStartsWithOrGreater,
         [FromQuery] string? nameStartsWith,
         [FromQuery] string? nameLessThan,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds,
         [FromQuery] bool enableTotalRecordCount = true,
         [FromQuery] bool? enableImages = true)
     {

+ 8 - 8
Jellyfin.Api/Controllers/TvShowsController.cs

@@ -77,12 +77,12 @@ public class TvShowsController : BaseJellyfinApiController
         [FromQuery] Guid? userId,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] Guid? seriesId,
         [FromQuery] Guid? parentId,
         [FromQuery] bool? enableImages,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] bool? enableUserData,
         [FromQuery] DateTime? nextUpDateCutoff,
         [FromQuery] bool enableTotalRecordCount = true,
@@ -143,11 +143,11 @@ public class TvShowsController : BaseJellyfinApiController
         [FromQuery] Guid? userId,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] Guid? parentId,
         [FromQuery] bool? enableImages,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] bool? enableUserData)
     {
         userId = RequestHelpers.GetUserId(User, userId);
@@ -208,7 +208,7 @@ public class TvShowsController : BaseJellyfinApiController
     public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
         [FromRoute, Required] Guid seriesId,
         [FromQuery] Guid? userId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] int? season,
         [FromQuery] Guid? seasonId,
         [FromQuery] bool? isMissing,
@@ -218,7 +218,7 @@ public class TvShowsController : BaseJellyfinApiController
         [FromQuery] int? limit,
         [FromQuery] bool? enableImages,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] bool? enableUserData,
         [FromQuery] ItemSortBy? sortBy)
     {
@@ -332,13 +332,13 @@ public class TvShowsController : BaseJellyfinApiController
     public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
         [FromRoute, Required] Guid seriesId,
         [FromQuery] Guid? userId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
         [FromQuery] bool? isSpecialSeason,
         [FromQuery] bool? isMissing,
         [FromQuery] Guid? adjacentTo,
         [FromQuery] bool? enableImages,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] bool? enableUserData)
     {
         userId = RequestHelpers.GetUserId(User, userId);

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

@@ -98,7 +98,7 @@ public class UniversalAudioController : BaseJellyfinApiController
     [ProducesAudioFile]
     public async Task<ActionResult> GetUniversalAudioStream(
         [FromRoute, Required] Guid itemId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] container,
         [FromQuery] string? mediaSourceId,
         [FromQuery] string? deviceId,
         [FromQuery] Guid? userId,

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

@@ -523,12 +523,12 @@ public class UserLibraryController : BaseJellyfinApiController
     public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia(
         [FromQuery] Guid? userId,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
         [FromQuery] bool? isPlayed,
         [FromQuery] bool? enableImages,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] bool? enableUserData,
         [FromQuery] int limit = 20,
         [FromQuery] bool groupItems = true)
@@ -608,12 +608,12 @@ public class UserLibraryController : BaseJellyfinApiController
     public ActionResult<IEnumerable<BaseItemDto>> GetLatestMediaLegacy(
         [FromRoute, Required] Guid userId,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
         [FromQuery] bool? isPlayed,
         [FromQuery] bool? enableImages,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] bool? enableUserData,
         [FromQuery] int limit = 20,
         [FromQuery] bool groupItems = true)

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

@@ -66,7 +66,7 @@ public class UserViewsController : BaseJellyfinApiController
     public QueryResult<BaseItemDto> GetUserViews(
         [FromQuery] Guid? userId,
         [FromQuery] bool? includeExternalContent,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] CollectionType?[] presetViews,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] CollectionType?[] presetViews,
         [FromQuery] bool includeHidden = false)
     {
         userId = RequestHelpers.GetUserId(User, userId);
@@ -110,7 +110,7 @@ public class UserViewsController : BaseJellyfinApiController
     public QueryResult<BaseItemDto> GetUserViewsLegacy(
         [FromRoute, Required] Guid userId,
         [FromQuery] bool? includeExternalContent,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] CollectionType?[] presetViews,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] CollectionType?[] presetViews,
         [FromQuery] bool includeHidden = false)
         => GetUserViews(userId, includeExternalContent, presetViews, includeHidden);
 

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

@@ -184,7 +184,7 @@ public class VideosController : BaseJellyfinApiController
     [Authorize(Policy = Policies.RequiresElevation)]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     [ProducesResponseType(StatusCodes.Status400BadRequest)]
-    public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+    public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids)
     {
         var userId = User.GetUserId();
         var items = ids

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

@@ -72,16 +72,16 @@ public class YearsController : BaseJellyfinApiController
     public ActionResult<QueryResult<BaseItemDto>> GetYears(
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
         [FromQuery] Guid? parentId,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
         [FromQuery] bool? enableUserData,
         [FromQuery] int? imageTypeLimit,
-        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] Guid? userId,
         [FromQuery] bool recursive = true,
         [FromQuery] bool? enableImages = true)

+ 6 - 6
Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs → Jellyfin.Api/ModelBinders/CommaDelimitedCollectionModelBinder.cs

@@ -8,18 +8,18 @@ using Microsoft.Extensions.Logging;
 namespace Jellyfin.Api.ModelBinders;
 
 /// <summary>
-/// Comma delimited array model binder.
+/// Comma delimited collection model binder.
 /// Returns an empty array of specified type if there is no query parameter.
 /// </summary>
-public class CommaDelimitedArrayModelBinder : IModelBinder
+public class CommaDelimitedCollectionModelBinder : IModelBinder
 {
-    private readonly ILogger<CommaDelimitedArrayModelBinder> _logger;
+    private readonly ILogger<CommaDelimitedCollectionModelBinder> _logger;
 
     /// <summary>
-    /// Initializes a new instance of the <see cref="CommaDelimitedArrayModelBinder"/> class.
+    /// Initializes a new instance of the <see cref="CommaDelimitedCollectionModelBinder"/> class.
     /// </summary>
-    /// <param name="logger">Instance of the <see cref="ILogger{CommaDelimitedArrayModelBinder}"/> interface.</param>
-    public CommaDelimitedArrayModelBinder(ILogger<CommaDelimitedArrayModelBinder> logger)
+    /// <param name="logger">Instance of the <see cref="ILogger{CommaDelimitedCollectionModelBinder}"/> interface.</param>
+    public CommaDelimitedCollectionModelBinder(ILogger<CommaDelimitedCollectionModelBinder> logger)
     {
         _logger = logger;
     }

+ 7 - 7
Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs → Jellyfin.Api/ModelBinders/PipeDelimitedCollectionModelBinder.cs

@@ -8,18 +8,18 @@ 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.
+/// Comma delimited collection model binder.
+/// Returns an empty collection of specified type if there is no query parameter.
 /// </summary>
-public class PipeDelimitedArrayModelBinder : IModelBinder
+public class PipeDelimitedCollectionModelBinder : IModelBinder
 {
-    private readonly ILogger<PipeDelimitedArrayModelBinder> _logger;
+    private readonly ILogger<PipeDelimitedCollectionModelBinder> _logger;
 
     /// <summary>
-    /// Initializes a new instance of the <see cref="PipeDelimitedArrayModelBinder"/> class.
+    /// Initializes a new instance of the <see cref="PipeDelimitedCollectionModelBinder"/> class.
     /// </summary>
-    /// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedArrayModelBinder}"/> interface.</param>
-    public PipeDelimitedArrayModelBinder(ILogger<PipeDelimitedArrayModelBinder> logger)
+    /// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedCollectionModelBinder}"/> interface.</param>
+    public PipeDelimitedCollectionModelBinder(ILogger<PipeDelimitedCollectionModelBinder> logger)
     {
         _logger = logger;
     }

+ 7 - 7
Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs

@@ -17,7 +17,7 @@ public class GetProgramsDto
     /// <summary>
     /// Gets or sets the channels to return guide information for.
     /// </summary>
-    [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+    [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
     public IReadOnlyList<Guid>? ChannelIds { get; set; }
 
     /// <summary>
@@ -93,25 +93,25 @@ public class GetProgramsDto
     /// <summary>
     /// Gets or sets specify one or more sort orders, comma delimited. Options: Name, StartDate.
     /// </summary>
-    [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+    [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
     public IReadOnlyList<ItemSortBy>? SortBy { get; set; }
 
     /// <summary>
     /// Gets or sets sort order.
     /// </summary>
-    [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+    [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
     public IReadOnlyList<SortOrder>? SortOrder { get; set; }
 
     /// <summary>
     /// Gets or sets the genres to return guide information for.
     /// </summary>
-    [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))]
+    [JsonConverter(typeof(JsonPipeDelimitedCollectionConverterFactory))]
     public IReadOnlyList<string>? Genres { get; set; }
 
     /// <summary>
     /// Gets or sets the genre ids to return guide information for.
     /// </summary>
-    [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+    [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
     public IReadOnlyList<Guid>? GenreIds { get; set; }
 
     /// <summary>
@@ -133,7 +133,7 @@ public class GetProgramsDto
     /// <summary>
     /// Gets or sets the image types to include in the output.
     /// </summary>
-    [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+    [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
     public IReadOnlyList<ImageType>? EnableImageTypes { get; set; }
 
     /// <summary>
@@ -154,6 +154,6 @@ public class GetProgramsDto
     /// <summary>
     /// Gets or sets specify additional fields of information to return in the output.
     /// </summary>
-    [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+    [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
     public IReadOnlyList<ItemFields>? Fields { get; set; }
 }

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

@@ -20,7 +20,7 @@ public class CreatePlaylistDto
     /// <summary>
     /// Gets or sets item ids to add to the playlist.
     /// </summary>
-    [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+    [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
     public IReadOnlyList<Guid> Ids { get; set; } = [];
 
     /// <summary>

+ 1 - 1
Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs

@@ -19,7 +19,7 @@ public class UpdatePlaylistDto
     /// <summary>
     /// Gets or sets item ids of the playlist.
     /// </summary>
-    [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+    [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
     public IReadOnlyList<Guid>? Ids { get; set; }
 
     /// <summary>

+ 3 - 2
Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs

@@ -71,8 +71,9 @@ public class ActivityLogWebSocketListener : BasePeriodicWebSocketListener<Activi
     /// <param name="message">The message.</param>
     protected override void Start(WebSocketMessageInfo message)
     {
-        if (message.Connection.AuthorizationInfo.User is null
-            || !message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
+        if (!message.Connection.AuthorizationInfo.IsApiKey
+            && (message.Connection.AuthorizationInfo.User is null
+                || !message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)))
         {
             throw new AuthenticationException("Only admin users can retrieve the activity log.");
         }

+ 3 - 2
Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs

@@ -80,8 +80,9 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
     /// <param name="message">The message.</param>
     protected override void Start(WebSocketMessageInfo message)
     {
-        if (message.Connection.AuthorizationInfo.User is null
-            || !message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
+        if (!message.Connection.AuthorizationInfo.IsApiKey
+            && (message.Connection.AuthorizationInfo.User is null
+                || !message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)))
         {
             throw new AuthenticationException("Only admin users can subscribe to session information.");
         }

+ 1 - 1
Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs

@@ -22,7 +22,7 @@ public class BaseItemEntity
 
     public DateTime? EndDate { get; set; }
 
-    public string? ChannelId { get; set; }
+    public Guid? ChannelId { get; set; }
 
     public bool IsMovie { get; set; }
 

+ 1 - 0
Jellyfin.Database/Jellyfin.Database.Providers.SqLite/Migrations/.gitattributes

@@ -0,0 +1 @@
+JellyfinDbModelSnapshot.cs binary

+ 1595 - 0
Jellyfin.Database/Jellyfin.Database.Providers.SqLite/Migrations/20250214031148_ChannelIdGuid.Designer.cs

@@ -0,0 +1,1595 @@
+// <auto-generated />
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    [DbContext(typeof(JellyfinDbContext))]
+    [Migration("20250214031148_ChannelIdGuid")]
+    partial class ChannelIdGuid
+    {
+        /// <inheritdoc />
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DayOfWeek")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<double>("EndHour")
+                        .HasColumnType("REAL");
+
+                    b.Property<double>("StartHour")
+                        .HasColumnType("REAL");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("AccessSchedules");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ItemId")
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("LogSeverity")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Overview")
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("ShortOverview")
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Type")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DateCreated");
+
+                    b.ToTable("ActivityLogs");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("ParentItemId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("ItemId", "ParentItemId");
+
+                    b.HasIndex("ParentItemId");
+
+                    b.ToTable("AncestorIds");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Index")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Codec")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CodecTag")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Comment")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Filename")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("MimeType")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("ItemId", "Index");
+
+                    b.ToTable("AttachmentStreamInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Album")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AlbumArtists")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Artists")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("Audio")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("ChannelId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CleanName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<float?>("CommunityRating")
+                        .HasColumnType("REAL");
+
+                    b.Property<float?>("CriticRating")
+                        .HasColumnType("REAL");
+
+                    b.Property<string>("CustomRating")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Data")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("DateLastMediaAdded")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("DateLastRefreshed")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("DateLastSaved")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("DateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("EndDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("EpisodeTitle")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ExternalId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ExternalSeriesId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ExternalServiceId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ExtraIds")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("ExtraType")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("ForcedSortName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Genres")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("IndexNumber")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("InheritedParentalRatingValue")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsFolder")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsInMixedFolder")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsLocked")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsMovie")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsRepeat")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsSeries")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsVirtualItem")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<float?>("LUFS")
+                        .HasColumnType("REAL");
+
+                    b.Property<string>("MediaType")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .HasColumnType("TEXT");
+
+                    b.Property<float?>("NormalizationGain")
+                        .HasColumnType("REAL");
+
+                    b.Property<string>("OfficialRating")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("OriginalTitle")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Overview")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("OwnerId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid?>("ParentId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("ParentIndexNumber")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Path")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PreferredMetadataCountryCode")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PreferredMetadataLanguage")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("PremiereDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PresentationUniqueKey")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PrimaryVersionId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ProductionLocations")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("ProductionYear")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<long?>("RunTimeTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("SeasonId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("SeasonName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid?>("SeriesId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("SeriesName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("SeriesPresentationUniqueKey")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ShowId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<long?>("Size")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SortName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("StartDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Studios")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Tagline")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Tags")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid?>("TopParentId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("TotalBitrate")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Type")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("UnratedType")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("ParentId");
+
+                    b.HasIndex("Path");
+
+                    b.HasIndex("PresentationUniqueKey");
+
+                    b.HasIndex("TopParentId", "Id");
+
+                    b.HasIndex("Type", "TopParentId", "Id");
+
+                    b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+                    b.HasIndex("Type", "TopParentId", "StartDate");
+
+                    b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem");
+
+                    b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+                    b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+                    b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+                    b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+                    b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+                    b.ToTable("BaseItems");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<byte[]>("Blurhash")
+                        .HasColumnType("BLOB");
+
+                    b.Property<DateTime>("DateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ImageType")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Path")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("ItemId");
+
+                    b.ToTable("BaseItemImageInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b =>
+                {
+                    b.Property<int>("Id")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id", "ItemId");
+
+                    b.HasIndex("ItemId");
+
+                    b.ToTable("BaseItemMetadataFields");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ProviderId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ProviderValue")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("ItemId", "ProviderId");
+
+                    b.HasIndex("ProviderId", "ProviderValue", "ItemId");
+
+                    b.ToTable("BaseItemProviders");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b =>
+                {
+                    b.Property<int>("Id")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id", "ItemId");
+
+                    b.HasIndex("ItemId");
+
+                    b.ToTable("BaseItemTrailerTypes");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ChapterIndex")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime?>("ImageDateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ImagePath")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .HasColumnType("TEXT");
+
+                    b.Property<long>("StartPositionTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "ChapterIndex");
+
+                    b.ToTable("Chapters");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Key")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "ItemId", "Client", "Key")
+                        .IsUnique();
+
+                    b.ToTable("CustomItemDisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ChromecastVersion")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DashboardTheme")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("EnableNextVideoInfoOverlay")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("IndexBy")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ScrollDirection")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("ShowBackdrop")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("ShowSidebar")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SkipBackwardLength")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SkipForwardLength")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("TvHome")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "ItemId", "Client")
+                        .IsUnique();
+
+                    b.ToTable("DisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DisplayPreferencesId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Order")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DisplayPreferencesId");
+
+                    b.ToTable("HomeSection");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime>("LastModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Path")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId")
+                        .IsUnique();
+
+                    b.ToTable("ImageInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("IndexBy")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("RememberIndexing")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberSorting")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SortBy")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("SortOrder")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ViewType")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("ItemDisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b =>
+                {
+                    b.Property<Guid>("ItemValueId")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CleanValue")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Value")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("ItemValueId");
+
+                    b.HasIndex("Type", "CleanValue")
+                        .IsUnique();
+
+                    b.ToTable("ItemValues");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b =>
+                {
+                    b.Property<Guid>("ItemValueId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("ItemValueId", "ItemId");
+
+                    b.HasIndex("ItemId");
+
+                    b.ToTable("ItemValuesMap");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<long>("EndTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("SegmentProviderId")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<long>("StartTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("MediaSegments");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("StreamIndex")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AspectRatio")
+                        .HasColumnType("TEXT");
+
+                    b.Property<float?>("AverageFrameRate")
+                        .HasColumnType("REAL");
+
+                    b.Property<int?>("BitDepth")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("BitRate")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("BlPresentFlag")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("ChannelLayout")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("Channels")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Codec")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CodecTag")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CodecTimeBase")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ColorPrimaries")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ColorSpace")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ColorTransfer")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Comment")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("DvBlSignalCompatibilityId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("DvLevel")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("DvProfile")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("DvVersionMajor")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("DvVersionMinor")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("ElPresentFlag")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool?>("IsAnamorphic")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool?>("IsAvc")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsDefault")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsExternal")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsForced")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool?>("IsHearingImpaired")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool?>("IsInterlaced")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("KeyFrames")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Language")
+                        .HasColumnType("TEXT");
+
+                    b.Property<float?>("Level")
+                        .HasColumnType("REAL");
+
+                    b.Property<string>("NalLengthSize")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Path")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PixelFormat")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Profile")
+                        .HasColumnType("TEXT");
+
+                    b.Property<float?>("RealFrameRate")
+                        .HasColumnType("REAL");
+
+                    b.Property<int?>("RefFrames")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("Rotation")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("RpuPresentFlag")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("SampleRate")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("StreamType")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("TimeBase")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Title")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "StreamIndex");
+
+                    b.HasIndex("StreamIndex");
+
+                    b.HasIndex("StreamType");
+
+                    b.HasIndex("StreamIndex", "StreamType");
+
+                    b.HasIndex("StreamIndex", "StreamType", "Language");
+
+                    b.ToTable("MediaStreamInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.People", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PersonType")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Name");
+
+                    b.ToTable("Peoples");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("PeopleId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("ListOrder")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Role")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("SortOrder")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "PeopleId");
+
+                    b.HasIndex("PeopleId");
+
+                    b.HasIndex("ItemId", "ListOrder");
+
+                    b.HasIndex("ItemId", "SortOrder");
+
+                    b.ToTable("PeopleBaseItemMap");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Kind")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("Permission_Permissions_Guid")
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("Value")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "Kind")
+                        .IsUnique()
+                        .HasFilter("[UserId] IS NOT NULL");
+
+                    b.ToTable("Permissions");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Kind")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("Preference_Preferences_Guid")
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .IsRequired()
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "Kind")
+                        .IsUnique()
+                        .HasFilter("[UserId] IS NOT NULL");
+
+                    b.ToTable("Preferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AccessToken")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateLastActivity")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("AccessToken")
+                        .IsUnique();
+
+                    b.ToTable("ApiKeys");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AccessToken")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AppName")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AppVersion")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateLastActivity")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceId")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceName")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("IsActive")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DeviceId");
+
+                    b.HasIndex("AccessToken", "DateLastActivity");
+
+                    b.HasIndex("DeviceId", "DateLastActivity");
+
+                    b.HasIndex("UserId", "DeviceId");
+
+                    b.ToTable("Devices");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("CustomName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceId")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DeviceId")
+                        .IsUnique();
+
+                    b.ToTable("DeviceOptions");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Bandwidth")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Interval")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ThumbnailCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileHeight")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileWidth")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "Width");
+
+                    b.ToTable("TrickplayInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AudioLanguagePreference")
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AuthenticationProviderId")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CastReceiverId")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("DisplayCollectionsView")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("DisplayMissingEpisodes")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableAutoLogin")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableLocalPassword")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableNextEpisodeAutoPlay")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableUserPreferenceAccess")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("HidePlayedInLatest")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<long>("InternalId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("InvalidLoginAttemptCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime?>("LastActivityDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("LastLoginDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("LoginAttemptsBeforeLockout")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("MaxActiveSessions")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("MaxParentalAgeRating")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("MustUpdatePassword")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Password")
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PasswordResetProviderId")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("PlayDefaultAudioTrack")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberAudioSelections")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberSubtitleSelections")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("RemoteClientBitrateLimit")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SubtitleLanguagePreference")
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("SubtitleMode")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SyncPlayAccess")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Username")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT")
+                        .UseCollation("NOCASE");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Username")
+                        .IsUnique();
+
+                    b.ToTable("Users");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CustomDataKey")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("AudioStreamIndex")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsFavorite")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime?>("LastPlayedDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool?>("Likes")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("PlayCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<long>("PlaybackPositionTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("Played")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<double?>("Rating")
+                        .HasColumnType("REAL");
+
+                    b.Property<int?>("SubtitleStreamIndex")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "UserId", "CustomDataKey");
+
+                    b.HasIndex("UserId");
+
+                    b.HasIndex("ItemId", "UserId", "IsFavorite");
+
+                    b.HasIndex("ItemId", "UserId", "LastPlayedDate");
+
+                    b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks");
+
+                    b.HasIndex("ItemId", "UserId", "Played");
+
+                    b.ToTable("UserData");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("AccessSchedules")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("Children")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "ParentItem")
+                        .WithMany("ParentAncestors")
+                        .HasForeignKey("ParentItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+
+                    b.Navigation("ParentItem");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany()
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("Images")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("LockedFields")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("Provider")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("TrailerTypes")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("Chapters")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("DisplayPreferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null)
+                        .WithMany("HomeSections")
+                        .HasForeignKey("DisplayPreferencesId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithOne("ProfileImage")
+                        .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("ItemDisplayPreferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("ItemValues")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.HasOne("Jellyfin.Data.Entities.ItemValue", "ItemValue")
+                        .WithMany("BaseItemsMap")
+                        .HasForeignKey("ItemValueId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+
+                    b.Navigation("ItemValue");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("MediaStreams")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("Peoples")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.HasOne("Jellyfin.Data.Entities.People", "People")
+                        .WithMany("BaseItems")
+                        .HasForeignKey("PeopleId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+
+                    b.Navigation("People");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Permissions")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Preferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", "User")
+                        .WithMany()
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("UserData")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.HasOne("Jellyfin.Data.Entities.User", "User")
+                        .WithMany()
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b =>
+                {
+                    b.Navigation("Chapters");
+
+                    b.Navigation("Children");
+
+                    b.Navigation("Images");
+
+                    b.Navigation("ItemValues");
+
+                    b.Navigation("LockedFields");
+
+                    b.Navigation("MediaStreams");
+
+                    b.Navigation("ParentAncestors");
+
+                    b.Navigation("Peoples");
+
+                    b.Navigation("Provider");
+
+                    b.Navigation("TrailerTypes");
+
+                    b.Navigation("UserData");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Navigation("HomeSections");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b =>
+                {
+                    b.Navigation("BaseItemsMap");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.People", b =>
+                {
+                    b.Navigation("BaseItems");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Navigation("AccessSchedules");
+
+                    b.Navigation("DisplayPreferences");
+
+                    b.Navigation("ItemDisplayPreferences");
+
+                    b.Navigation("Permissions");
+
+                    b.Navigation("Preferences");
+
+                    b.Navigation("ProfileImage");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}

+ 22 - 0
Jellyfin.Database/Jellyfin.Database.Providers.SqLite/Migrations/20250214031148_ChannelIdGuid.cs

@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    /// <inheritdoc />
+    public partial class ChannelIdGuid : Migration
+    {
+        /// <inheritdoc />
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            // NOOP, Guids and strings are stored the same in SQLite.
+        }
+
+        /// <inheritdoc />
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            // NOOP, Guids and strings are stored the same in SQLite.
+        }
+    }
+}

+ 2 - 2
Jellyfin.Database/Jellyfin.Database.Providers.SqLite/Migrations/JellyfinDbModelSnapshot.cs

@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
         protected override void BuildModel(ModelBuilder modelBuilder)
         {
 #pragma warning disable 612, 618
-            modelBuilder.HasAnnotation("ProductVersion", "9.0.1");
+            modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
 
             modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
                 {
@@ -152,7 +152,7 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.Property<int?>("Audio")
                         .HasColumnType("INTEGER");
 
-                    b.Property<string>("ChannelId")
+                    b.Property<Guid?>("ChannelId")
                         .HasColumnType("TEXT");
 
                     b.Property<string>("CleanName")

+ 10 - 8
Jellyfin.Server.Implementations/Item/BaseItemRepository.cs

@@ -553,7 +553,7 @@ public sealed class BaseItemRepository
         dto.Genres = entity.Genres?.Split('|') ?? [];
         dto.DateCreated = entity.DateCreated.GetValueOrDefault();
         dto.DateModified = entity.DateModified.GetValueOrDefault();
-        dto.ChannelId = string.IsNullOrWhiteSpace(entity.ChannelId) ? Guid.Empty : (Guid.TryParse(entity.ChannelId, out var channelId) ? channelId : Guid.Empty);
+        dto.ChannelId = entity.ChannelId ?? Guid.Empty;
         dto.DateLastRefreshed = entity.DateLastRefreshed.GetValueOrDefault();
         dto.DateLastSaved = entity.DateLastSaved.GetValueOrDefault();
         dto.OwnerId = string.IsNullOrWhiteSpace(entity.OwnerId) ? Guid.Empty : (Guid.TryParse(entity.OwnerId, out var ownerId) ? ownerId : Guid.Empty);
@@ -689,6 +689,7 @@ public sealed class BaseItemRepository
         entity.IndexNumber = dto.IndexNumber;
         entity.IsLocked = dto.IsLocked;
         entity.Name = dto.Name;
+        entity.CleanName = GetCleanValue(dto.Name);
         entity.OfficialRating = dto.OfficialRating;
         entity.Overview = dto.Overview;
         entity.ParentIndexNumber = dto.ParentIndexNumber;
@@ -716,7 +717,7 @@ public sealed class BaseItemRepository
         entity.Genres = string.Join('|', dto.Genres);
         entity.DateCreated = dto.DateCreated;
         entity.DateModified = dto.DateModified;
-        entity.ChannelId = dto.ChannelId.ToString();
+        entity.ChannelId = dto.ChannelId;
         entity.DateLastRefreshed = dto.DateLastRefreshed;
         entity.DateLastSaved = dto.DateLastSaved;
         entity.OwnerId = dto.OwnerId.ToString();
@@ -821,10 +822,9 @@ public sealed class BaseItemRepository
             entity.StartDate = hasStartDate.StartDate;
         }
 
+        entity.UnratedType = dto.GetBlockUnratedType().ToString();
+
         // Fields that are present in the DB but are never actually used
-        // dto.UnratedType = entity.UnratedType;
-        // dto.TopParentId = entity.TopParentId;
-        // dto.CleanName = entity.CleanName;
         // dto.UserDataKey = entity.UserDataKey;
 
         if (dto is Folder folder)
@@ -854,7 +854,10 @@ public sealed class BaseItemRepository
         }
 
         // query = query.DistinctBy(e => e.CleanValue);
-        return query.Select(e => e.ItemValue.CleanValue).ToArray();
+        return query.Select(e => e.ItemValue)
+            .GroupBy(e => e.CleanValue)
+            .Select(e => e.First().Value)
+            .ToArray();
     }
 
     private static bool TypeRequiresDeserialization(Type type)
@@ -1448,8 +1451,7 @@ public sealed class BaseItemRepository
 
         if (filter.ChannelIds.Count > 0)
         {
-            var channelIds = filter.ChannelIds.Select(e => e.ToString("N", CultureInfo.InvariantCulture)).ToArray();
-            baseQuery = baseQuery.Where(e => channelIds.Contains(e.ChannelId));
+            baseQuery = baseQuery.Where(e => e.ChannelId != null && filter.ChannelIds.Contains(e.ChannelId.Value));
         }
 
         if (!filter.ParentId.IsEmpty())

+ 2 - 2
Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs

@@ -88,7 +88,7 @@ public class MediaStreamRepository : IMediaStreamRepository
             query = query.Where(e => e.StreamType == typeValue);
         }
 
-        return query;
+        return query.OrderBy(e => e.StreamIndex);
     }
 
     private MediaStream Map(MediaStreamInfo entity)
@@ -137,7 +137,7 @@ public class MediaStreamRepository : IMediaStreamRepository
         dto.ElPresentFlag = entity.ElPresentFlag;
         dto.BlPresentFlag = entity.BlPresentFlag;
         dto.DvBlSignalCompatibilityId = entity.DvBlSignalCompatibilityId;
-        dto.IsHearingImpaired = entity.IsHearingImpaired;
+        dto.IsHearingImpaired = entity.IsHearingImpaired.GetValueOrDefault();
         dto.Rotation = entity.Rotation;
 
         if (dto.Type is MediaStreamType.Audio or MediaStreamType.Subtitle)

+ 5 - 1
Jellyfin.Server.Implementations/Item/PeopleRepository.cs

@@ -11,6 +11,9 @@ using Microsoft.EntityFrameworkCore;
 
 namespace Jellyfin.Server.Implementations.Item;
 #pragma warning disable RS0030 // Do not use banned APIs
+#pragma warning disable CA1304 // Specify CultureInfo
+#pragma warning disable CA1311 // Specify a culture or use an invariant version
+#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
 
 /// <summary>
 /// Manager for handling people.
@@ -155,7 +158,8 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
 
         if (!string.IsNullOrWhiteSpace(filter.NameContains))
         {
-            query = query.Where(e => e.Name.Contains(filter.NameContains));
+            var nameContainsUpper = filter.NameContains.ToUpper();
+            query = query.Where(e => e.Name.ToUpper().Contains(nameContainsUpper));
         }
 
         return query;

+ 1 - 1
Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs

@@ -673,7 +673,7 @@ public class MigrateLibraryDb : IMigrationRoutine
             entity.EndDate = endDate;
         }
 
-        if (reader.TryGetString(index++, out var guid))
+        if (reader.TryGetGuid(index++, out var guid))
         {
             entity.ChannelId = guid;
         }

+ 2 - 2
MediaBrowser.Controller/Channels/Channel.cs

@@ -23,7 +23,7 @@ namespace MediaBrowser.Controller.Channels
         [JsonIgnore]
         public override SourceType SourceType => SourceType.Channel;
 
-        public override bool IsVisible(User user)
+        public override bool IsVisible(User user, bool skipAllowedTagsCheck = false)
         {
             var blockedChannelsPreference = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedChannels);
             if (blockedChannelsPreference.Length != 0)
@@ -42,7 +42,7 @@ namespace MediaBrowser.Controller.Channels
                 }
             }
 
-            return base.IsVisible(user);
+            return base.IsVisible(user, skipAllowedTagsCheck);
         }
 
         protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query)

+ 9 - 7
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -1304,7 +1304,7 @@ namespace MediaBrowser.Controller.Entities
                 return false;
             }
 
-            if (GetParents().Any(i => !i.IsVisible(user)))
+            if (GetParents().Any(i => !i.IsVisible(user, true)))
             {
                 return false;
             }
@@ -1526,13 +1526,14 @@ namespace MediaBrowser.Controller.Entities
         /// Determines if a given user has access to this item.
         /// </summary>
         /// <param name="user">The user.</param>
+        /// <param name="skipAllowedTagsCheck">Don't check for allowed tags.</param>
         /// <returns><c>true</c> if [is parental allowed] [the specified user]; otherwise, <c>false</c>.</returns>
         /// <exception cref="ArgumentNullException">If user is null.</exception>
-        public bool IsParentalAllowed(User user)
+        public bool IsParentalAllowed(User user, bool skipAllowedTagsCheck)
         {
             ArgumentNullException.ThrowIfNull(user);
 
-            if (!IsVisibleViaTags(user))
+            if (!IsVisibleViaTags(user, skipAllowedTagsCheck))
             {
                 return false;
             }
@@ -1604,7 +1605,7 @@ namespace MediaBrowser.Controller.Entities
             return list.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
         }
 
-        private bool IsVisibleViaTags(User user)
+        private bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck)
         {
             var allTags = GetInheritedTags();
             if (user.GetPreference(PreferenceKind.BlockedTags).Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase)))
@@ -1619,7 +1620,7 @@ namespace MediaBrowser.Controller.Entities
             }
 
             var allowedTagsPreference = user.GetPreference(PreferenceKind.AllowedTags);
-            if (allowedTagsPreference.Length != 0 && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase)))
+            if (!skipAllowedTagsCheck && allowedTagsPreference.Length != 0 && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase)))
             {
                 return false;
             }
@@ -1659,13 +1660,14 @@ namespace MediaBrowser.Controller.Entities
         /// Default is just parental allowed. Can be overridden for more functionality.
         /// </summary>
         /// <param name="user">The user.</param>
+        /// <param name="skipAllowedTagsCheck">Don't check for allowed tags.</param>
         /// <returns><c>true</c> if the specified user is visible; otherwise, <c>false</c>.</returns>
         /// <exception cref="ArgumentNullException"><paramref name="user" /> is <c>null</c>.</exception>
-        public virtual bool IsVisible(User user)
+        public virtual bool IsVisible(User user, bool skipAllowedTagsCheck = false)
         {
             ArgumentNullException.ThrowIfNull(user);
 
-            return IsParentalAllowed(user);
+            return IsParentalAllowed(user, skipAllowedTagsCheck);
         }
 
         public virtual bool IsVisibleStandalone(User user)

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

@@ -96,11 +96,11 @@ namespace MediaBrowser.Controller.Entities
             return GetLibraryOptions(Path);
         }
 
-        public override bool IsVisible(User user)
+        public override bool IsVisible(User user, bool skipAllowedTagsCheck = false)
         {
             if (GetLibraryOptions().Enabled)
             {
-                return base.IsVisible(user);
+                return base.IsVisible(user, skipAllowedTagsCheck);
             }
 
             return false;

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

@@ -220,7 +220,7 @@ namespace MediaBrowser.Controller.Entities
             LibraryManager.CreateItem(item, this);
         }
 
-        public override bool IsVisible(User user)
+        public override bool IsVisible(User user, bool skipAllowedTagsCheck = false)
         {
             if (this is ICollectionFolder && this is not BasePluginFolder)
             {
@@ -242,7 +242,7 @@ namespace MediaBrowser.Controller.Entities
                 }
             }
 
-            return base.IsVisible(user);
+            return base.IsVisible(user, skipAllowedTagsCheck);
         }
 
         /// <summary>
@@ -453,7 +453,7 @@ namespace MediaBrowser.Controller.Entities
 
                 if (newItems.Count > 0)
                 {
-                    LibraryManager.CreateOrUpdateItems(newItems, this, cancellationToken);
+                    LibraryManager.CreateItems(newItems, this, cancellationToken);
                 }
             }
             else

+ 3 - 3
MediaBrowser.Controller/Entities/Movies/BoxSet.cs

@@ -146,14 +146,14 @@ namespace MediaBrowser.Controller.Entities.Movies
             return GetItemLookupInfo<BoxSetInfo>();
         }
 
-        public override bool IsVisible(User user)
+        public override bool IsVisible(User user, bool skipAllowedTagsCheck = false)
         {
             if (IsLegacyBoxSet)
             {
-                return base.IsVisible(user);
+                return base.IsVisible(user, skipAllowedTagsCheck);
             }
 
-            if (base.IsVisible(user))
+            if (base.IsVisible(user, skipAllowedTagsCheck))
             {
                 if (LinkedChildren.Length == 0)
                 {

+ 1 - 1
MediaBrowser.Controller/Library/ILibraryManager.cs

@@ -258,7 +258,7 @@ namespace MediaBrowser.Controller.Library
         /// <param name="items">Items to create.</param>
         /// <param name="parent">Parent of new items.</param>
         /// <param name="cancellationToken">CancellationToken to use for operation.</param>
-        void CreateOrUpdateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken);
+        void CreateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken);
 
         /// <summary>
         /// Updates the item.

+ 2 - 2
MediaBrowser.Controller/Playlists/Playlist.cs

@@ -228,11 +228,11 @@ namespace MediaBrowser.Controller.Playlists
             return [item];
         }
 
-        public override bool IsVisible(User user)
+        public override bool IsVisible(User user, bool skipAllowedTagsCheck = false)
         {
             if (!IsSharedItem)
             {
-                return base.IsVisible(user);
+                return base.IsVisible(user, skipAllowedTagsCheck);
             }
 
             if (OpenAccess)

+ 7 - 1
MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs

@@ -122,7 +122,13 @@ namespace MediaBrowser.MediaEncoding.Encoder
             _jsonSerializerOptions = new JsonSerializerOptions(JsonDefaults.Options);
             _jsonSerializerOptions.Converters.Add(new JsonBoolStringConverter());
 
-            var semaphoreCount = 2 * Environment.ProcessorCount;
+            // Although the type is not nullable, this might still be null during unit tests
+            var semaphoreCount = serverConfig.Configuration?.ParallelImageEncodingLimit ?? 0;
+            if (semaphoreCount < 1)
+            {
+                semaphoreCount = Environment.ProcessorCount;
+            }
+
             _thumbnailResourcePool = new(semaphoreCount);
         }
 

+ 2 - 2
MediaBrowser.Model/Dto/ClientCapabilitiesDto.cs

@@ -15,13 +15,13 @@ public class ClientCapabilitiesDto
     /// <summary>
     /// Gets or sets the list of playable media types.
     /// </summary>
-    [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+    [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
     public IReadOnlyList<MediaType> PlayableMediaTypes { get; set; } = [];
 
     /// <summary>
     /// Gets or sets the list of supported commands.
     /// </summary>
-    [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+    [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
     public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } = [];
 
     /// <summary>

+ 1 - 1
MediaBrowser.Model/Entities/MediaStream.cs

@@ -500,7 +500,7 @@ namespace MediaBrowser.Model.Entities
         /// Gets or sets a value indicating whether this instance is for the hearing impaired.
         /// </summary>
         /// <value><c>true</c> if this instance is for the hearing impaired; otherwise, <c>false</c>.</value>
-        public bool? IsHearingImpaired { get; set; }
+        public bool IsHearingImpaired { get; set; }
 
         /// <summary>
         /// Gets or sets the height.

+ 15 - 2
MediaBrowser.Providers/Manager/ItemImageProvider.cs

@@ -6,6 +6,7 @@ using System.IO;
 using System.Linq;
 using System.Net;
 using System.Net.Http;
+using System.Net.Mime;
 using System.Threading;
 using System.Threading.Tasks;
 using MediaBrowser.Controller.Entities;
@@ -551,10 +552,16 @@ namespace MediaBrowser.Providers.Manager
                     var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
                     await using (stream.ConfigureAwait(false))
                     {
+                        var mimetype = response.Content.Headers.ContentType?.MediaType;
+                        if (mimetype is null || mimetype.Equals(MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase))
+                        {
+                            mimetype = MimeTypes.GetMimeType(response.RequestMessage.RequestUri.GetLeftPart(UriPartial.Path));
+                        }
+
                         await _providerManager.SaveImage(
                             item,
                             stream,
-                            response.Content.Headers.ContentType?.MediaType,
+                            mimetype,
                             type,
                             null,
                             cancellationToken).ConfigureAwait(false);
@@ -677,10 +684,16 @@ namespace MediaBrowser.Providers.Manager
                     var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
                     await using (stream.ConfigureAwait(false))
                     {
+                        var mimetype = response.Content.Headers.ContentType?.MediaType;
+                        if (mimetype is null || mimetype.Equals(MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase))
+                        {
+                            mimetype = MimeTypes.GetMimeType(response.RequestMessage.RequestUri.GetLeftPart(UriPartial.Path));
+                        }
+
                         await _providerManager.SaveImage(
                             item,
                             stream,
-                            response.Content.Headers.ContentType?.MediaType,
+                            mimetype,
                             imageType,
                             null,
                             cancellationToken).ConfigureAwait(false);

+ 3 - 20
MediaBrowser.Providers/Manager/ProviderManager.cs

@@ -205,27 +205,10 @@ namespace MediaBrowser.Providers.Manager
                     {
                         contentType = MediaTypeNames.Image.Png;
                     }
-                    else
-                    {
-                        // Deduce content type from file extension
-                        contentType = MimeTypes.GetMimeType(new Uri(url).GetLeftPart(UriPartial.Path));
-                    }
-
-                    // Throw if we still can't determine the content type
-                    if (string.IsNullOrEmpty(contentType))
-                    {
-                        throw new HttpRequestException("Invalid image received: contentType not set.", null, response.StatusCode);
-                    }
-                }
-
-                // TVDb will sometimes serve a rubbish 404 html page with a 200 OK code, because reasons...
-                if (contentType.Equals(MediaTypeNames.Text.Html, StringComparison.OrdinalIgnoreCase))
-                {
-                    throw new HttpRequestException("Invalid image received.", null, HttpStatusCode.NotFound);
                 }
 
-                // some iptv/epg providers don't correctly report media type, extract from url if no extension found
-                if (string.IsNullOrWhiteSpace(MimeTypes.ToExtension(contentType)))
+                // some providers don't correctly report media type, extract from url if no extension found
+                if (contentType is null || contentType.Equals(MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase))
                 {
                     // Strip query parameters from url to get actual path.
                     contentType = MimeTypes.GetMimeType(new Uri(url).GetLeftPart(UriPartial.Path));
@@ -233,7 +216,7 @@ namespace MediaBrowser.Providers.Manager
 
                 if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
                 {
-                    throw new HttpRequestException($"Request returned {contentType} instead of an image type", null, HttpStatusCode.NotFound);
+                    throw new HttpRequestException($"Request returned '{contentType}' instead of an image type", null, HttpStatusCode.NotFound);
                 }
 
                 var responseBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);

+ 3 - 3
MediaBrowser.Providers/MediaInfo/AudioFileProber.cs

@@ -176,9 +176,9 @@ namespace MediaBrowser.Providers.MediaInfo
 
             track.Title = string.IsNullOrEmpty(track.Title) ? mediaInfo.Name : track.Title;
             track.Album = string.IsNullOrEmpty(track.Album) ? mediaInfo.Album : track.Album;
-            track.Year ??= mediaInfo.ProductionYear;
-            track.TrackNumber ??= mediaInfo.IndexNumber;
-            track.DiscNumber ??= mediaInfo.ParentIndexNumber;
+            track.Year = track.Year is null or 0 ? mediaInfo.ProductionYear : track.Year;
+            track.TrackNumber = track.TrackNumber is null or 0 ? mediaInfo.IndexNumber : track.TrackNumber;
+            track.DiscNumber = track.DiscNumber is null or 0 ? mediaInfo.ParentIndexNumber : track.DiscNumber;
 
             if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
             {

+ 2 - 2
MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs

@@ -119,9 +119,9 @@ namespace MediaBrowser.Providers.MediaInfo
                                 || (mediaStream.Type == MediaStreamType.Subtitle && _type == DlnaProfileType.Subtitle))
                             {
                                 mediaStream.Index = startIndex++;
-                                mediaStream.IsDefault = pathInfo.IsDefault || mediaStream.IsDefault;
+                                mediaStream.IsDefault = pathInfo.IsDefault;
                                 mediaStream.IsForced = pathInfo.IsForced || mediaStream.IsForced;
-                                mediaStream.IsHearingImpaired = pathInfo.IsHearingImpaired || mediaStream.IsHearingImpaired.GetValueOrDefault();
+                                mediaStream.IsHearingImpaired = pathInfo.IsHearingImpaired || mediaStream.IsHearingImpaired;
 
                                 mediaStreams.Add(MergeMetadata(mediaStream, pathInfo));
                             }

+ 1 - 1
src/Jellyfin.Drawing/ImageProcessor.cs

@@ -68,7 +68,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
         var semaphoreCount = config.Configuration.ParallelImageEncodingLimit;
         if (semaphoreCount < 1)
         {
-            semaphoreCount = 2 * Environment.ProcessorCount;
+            semaphoreCount = Environment.ProcessorCount;
         }
 
         _parallelEncodingLimit = new(semaphoreCount);

+ 4 - 4
src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs → src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedCollectionConverter.cs

@@ -1,15 +1,15 @@
 namespace Jellyfin.Extensions.Json.Converters
 {
     /// <summary>
-    /// Convert comma delimited string to array of type.
+    /// Convert comma delimited string to collection of type.
     /// </summary>
     /// <typeparam name="T">Type to convert to.</typeparam>
-    public sealed class JsonCommaDelimitedArrayConverter<T> : JsonDelimitedArrayConverter<T>
+    public sealed class JsonCommaDelimitedCollectionConverter<T> : JsonDelimitedCollectionConverter<T>
     {
         /// <summary>
-        /// Initializes a new instance of the <see cref="JsonCommaDelimitedArrayConverter{T}"/> class.
+        /// Initializes a new instance of the <see cref="JsonCommaDelimitedCollectionConverter{T}"/> class.
         /// </summary>
-        public JsonCommaDelimitedArrayConverter() : base()
+        public JsonCommaDelimitedCollectionConverter() : base()
         {
         }
 

+ 7 - 4
src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs → src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedCollectionConverterFactory.cs

@@ -1,28 +1,31 @@
 using System;
+using System.Collections.Generic;
 using System.Text.Json;
 using System.Text.Json.Serialization;
 
 namespace Jellyfin.Extensions.Json.Converters
 {
     /// <summary>
-    /// Json comma delimited array converter factory.
+    /// Json comma delimited collection converter factory.
     /// </summary>
     /// <remarks>
     /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow.
     /// </remarks>
-    public class JsonCommaDelimitedArrayConverterFactory : JsonConverterFactory
+    public class JsonCommaDelimitedCollectionConverterFactory : JsonConverterFactory
     {
         /// <inheritdoc />
         public override bool CanConvert(Type typeToConvert)
         {
-            return true;
+            return typeToConvert.IsArray
+                || (typeToConvert.IsGenericType
+                    && (typeToConvert.GetGenericTypeDefinition().IsAssignableFrom(typeof(IReadOnlyCollection<>)) || typeToConvert.GetGenericTypeDefinition().IsAssignableFrom(typeof(IReadOnlyList<>))));
         }
 
         /// <inheritdoc />
         public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
         {
             var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0];
-            return (JsonConverter?)Activator.CreateInstance(typeof(JsonCommaDelimitedArrayConverter<>).MakeGenericType(structType));
+            return (JsonConverter?)Activator.CreateInstance(typeof(JsonCommaDelimitedCollectionConverter<>).MakeGenericType(structType));
         }
     }
 }

+ 12 - 26
src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs → src/Jellyfin.Extensions/Json/Converters/JsonDelimitedCollectionConverter.cs

@@ -10,14 +10,14 @@ namespace Jellyfin.Extensions.Json.Converters
     /// Convert delimited string to array of type.
     /// </summary>
     /// <typeparam name="T">Type to convert to.</typeparam>
-    public abstract class JsonDelimitedArrayConverter<T> : JsonConverter<T[]>
+    public abstract class JsonDelimitedCollectionConverter<T> : JsonConverter<IReadOnlyCollection<T>>
     {
         private readonly TypeConverter _typeConverter;
 
         /// <summary>
-        /// Initializes a new instance of the <see cref="JsonDelimitedArrayConverter{T}"/> class.
+        /// Initializes a new instance of the <see cref="JsonDelimitedCollectionConverter{T}"/> class.
         /// </summary>
-        protected JsonDelimitedArrayConverter()
+        protected JsonDelimitedCollectionConverter()
         {
             _typeConverter = TypeDescriptor.GetConverter(typeof(T));
         }
@@ -28,7 +28,7 @@ namespace Jellyfin.Extensions.Json.Converters
         protected virtual char Delimiter { get; }
 
         /// <inheritdoc />
-        public override T[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+        public override IReadOnlyCollection<T>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
         {
             if (reader.TokenType == JsonTokenType.String)
             {
@@ -56,35 +56,21 @@ namespace Jellyfin.Extensions.Json.Converters
                     }
                 }
 
-                return typedValues.ToArray();
+                if (typeToConvert.IsArray)
+                {
+                    return typedValues.ToArray();
+                }
+
+                return typedValues;
             }
 
             return JsonSerializer.Deserialize<T[]>(ref reader, options);
         }
 
         /// <inheritdoc />
-        public override void Write(Utf8JsonWriter writer, T[]? value, JsonSerializerOptions options)
+        public override void Write(Utf8JsonWriter writer, IReadOnlyCollection<T>? value, JsonSerializerOptions options)
         {
-            if (value is not null)
-            {
-                writer.WriteStartArray();
-                if (value.Length > 0)
-                {
-                    foreach (var it in value)
-                    {
-                        if (it is not null)
-                        {
-                            writer.WriteStringValue(it.ToString());
-                        }
-                    }
-                }
-
-                writer.WriteEndArray();
-            }
-            else
-            {
-                writer.WriteNullValue();
-            }
+            JsonSerializer.Serialize(writer, value, options);
         }
     }
 }

+ 3 - 3
src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs → src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedCollectionConverter.cs

@@ -4,12 +4,12 @@ namespace Jellyfin.Extensions.Json.Converters
     /// Convert Pipe delimited string to array of type.
     /// </summary>
     /// <typeparam name="T">Type to convert to.</typeparam>
-    public sealed class JsonPipeDelimitedArrayConverter<T> : JsonDelimitedArrayConverter<T>
+    public sealed class JsonPipeDelimitedCollectionConverter<T> : JsonDelimitedCollectionConverter<T>
     {
         /// <summary>
-        /// Initializes a new instance of the <see cref="JsonPipeDelimitedArrayConverter{T}"/> class.
+        /// Initializes a new instance of the <see cref="JsonPipeDelimitedCollectionConverter{T}"/> class.
         /// </summary>
-        public JsonPipeDelimitedArrayConverter() : base()
+        public JsonPipeDelimitedCollectionConverter() : base()
         {
         }
 

+ 7 - 4
src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs → src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedCollectionConverterFactory.cs

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

+ 46 - 62
src/Jellyfin.LiveTv/Guide/GuideManager.cs

@@ -3,7 +3,6 @@ using System.Collections.Generic;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
-using Jellyfin.Data.Entities.Libraries;
 using Jellyfin.Data.Enums;
 using Jellyfin.Extensions;
 using Jellyfin.LiveTv.Configuration;
@@ -210,7 +209,7 @@ public class GuideManager : IGuideManager
         progress.Report(15);
 
         numComplete = 0;
-        var programs = new List<LiveTvProgram>();
+        var programIds = new List<Guid>();
         var channels = new List<Guid>();
 
         var guideDays = GetGuideDays();
@@ -243,8 +242,8 @@ public class GuideManager : IGuideManager
                     DtoOptions = new DtoOptions(true)
                 }).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
 
-                var newPrograms = new List<Guid>();
-                var updatedPrograms = new List<Guid>();
+                var newPrograms = new List<LiveTvProgram>();
+                var updatedPrograms = new List<LiveTvProgram>();
 
                 foreach (var program in channelPrograms)
                 {
@@ -252,14 +251,14 @@ public class GuideManager : IGuideManager
                     var id = programItem.Id;
                     if (isNew)
                     {
-                        newPrograms.Add(id);
+                        newPrograms.Add(programItem);
                     }
                     else if (isUpdated)
                     {
-                        updatedPrograms.Add(id);
+                        updatedPrograms.Add(programItem);
                     }
 
-                    programs.Add(programItem);
+                    programIds.Add(programItem.Id);
 
                     isMovie |= program.IsMovie;
                     isSeries |= program.IsSeries;
@@ -276,21 +275,21 @@ public class GuideManager : IGuideManager
 
                 if (newPrograms.Count > 0)
                 {
-                    var newProgramDtos = programs.Where(b => newPrograms.Contains(b.Id)).ToList();
-                    _libraryManager.CreateOrUpdateItems(newProgramDtos, null, cancellationToken);
+                    _libraryManager.CreateItems(newPrograms, currentChannel, cancellationToken);
+
+                    await PreCacheImages(newPrograms, maxCacheDate).ConfigureAwait(false);
                 }
 
                 if (updatedPrograms.Count > 0)
                 {
-                    var updatedProgramDtos = programs.Where(b => updatedPrograms.Contains(b.Id)).ToList();
                     await _libraryManager.UpdateItemsAsync(
-                        updatedProgramDtos,
+                        updatedPrograms,
                         currentChannel,
                         ItemUpdateType.MetadataImport,
                         cancellationToken).ConfigureAwait(false);
-                }
 
-                await PreCacheImages(programs, maxCacheDate).ConfigureAwait(false);
+                    await PreCacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false);
+                }
 
                 currentChannel.IsMovie = isMovie;
                 currentChannel.IsNews = isNews;
@@ -326,7 +325,6 @@ public class GuideManager : IGuideManager
         }
 
         progress.Report(100);
-        var programIds = programs.Select(p => p.Id).ToList();
         return new Tuple<List<Guid>, List<Guid>>(channels, programIds);
     }
 
@@ -502,35 +500,27 @@ public class GuideManager : IGuideManager
             forceUpdate = true;
         }
 
-        var seriesId = info.SeriesId;
-
-        if (!item.ParentId.Equals(channel.Id))
+        var channelId = channel.Id;
+        if (!item.ParentId.Equals(channelId))
         {
+            item.ParentId = channel.Id;
             forceUpdate = true;
         }
 
-        item.ParentId = channel.Id;
-
         item.Audio = info.Audio;
-        item.ChannelId = channel.Id;
-        item.CommunityRating ??= info.CommunityRating;
-        if ((item.CommunityRating ?? 0).Equals(0))
-        {
-            item.CommunityRating = null;
-        }
-
+        item.ChannelId = channelId;
+        item.CommunityRating = info.CommunityRating;
         item.EpisodeTitle = info.EpisodeTitle;
         item.ExternalId = info.Id;
 
-        if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal))
+        var seriesId = info.SeriesId;
+        if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.OrdinalIgnoreCase))
         {
+            item.ExternalSeriesId = seriesId;
             forceUpdate = true;
         }
 
-        item.ExternalSeriesId = seriesId;
-
         var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle);
-
         if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle))
         {
             item.SeriesName = info.Name;
@@ -578,7 +568,6 @@ public class GuideManager : IGuideManager
         }
 
         item.Tags = tags.ToArray();
-
         item.Genres = info.Genres.ToArray();
 
         if (info.IsHD ?? false)
@@ -589,41 +578,35 @@ public class GuideManager : IGuideManager
 
         item.IsMovie = info.IsMovie;
         item.IsRepeat = info.IsRepeat;
-
         if (item.IsSeries != isSeries)
         {
+            item.IsSeries = isSeries;
             forceUpdate = true;
         }
 
-        item.IsSeries = isSeries;
-
         item.Name = info.Name;
-        item.OfficialRating ??= info.OfficialRating;
-        item.Overview ??= info.Overview;
+        item.OfficialRating = info.OfficialRating;
+        item.Overview = info.Overview;
         item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks;
-        item.ProviderIds = info.ProviderIds;
-
         foreach (var providerId in info.SeriesProviderIds)
         {
             info.ProviderIds["Series" + providerId.Key] = providerId.Value;
         }
 
+        item.ProviderIds = info.ProviderIds;
         if (item.StartDate != info.StartDate)
         {
+            item.StartDate = info.StartDate;
             forceUpdate = true;
         }
 
-        item.StartDate = info.StartDate;
-
         if (item.EndDate != info.EndDate)
         {
+            item.EndDate = info.EndDate;
             forceUpdate = true;
         }
 
-        item.EndDate = info.EndDate;
-
         item.ProductionYear = info.ProductionYear;
-
         if (!isSeries || info.IsRepeat)
         {
             item.PremiereDate = info.OriginalAirDate;
@@ -632,37 +615,35 @@ public class GuideManager : IGuideManager
         item.IndexNumber = info.EpisodeNumber;
         item.ParentIndexNumber = info.SeasonNumber;
 
-        forceUpdate = forceUpdate || UpdateImages(item, info);
+        forceUpdate |= UpdateImages(item, info);
 
         if (isNew)
         {
             item.OnMetadataChanged();
 
-            return (item, isNew, false);
+            return (item, true, false);
         }
 
-        var isUpdated = false;
-        if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag))
+        var isUpdated = forceUpdate;
+        var etag = info.Etag;
+        if (string.IsNullOrWhiteSpace(etag))
         {
             isUpdated = true;
         }
-        else
+        else if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase))
         {
-            var etag = info.Etag;
-
-            if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase))
-            {
-                item.SetProviderId(EtagKey, etag);
-                isUpdated = true;
-            }
+            item.SetProviderId(EtagKey, etag);
+            isUpdated = true;
         }
 
         if (isUpdated)
         {
             item.OnMetadataChanged();
+
+            return (item, false, true);
         }
 
-        return (item, isNew, isUpdated);
+        return (item, false, false);
     }
 
     private static bool UpdateImages(BaseItem item, ProgramInfo info)
@@ -679,7 +660,9 @@ public class GuideManager : IGuideManager
         updated |= UpdateImage(ImageType.Logo, item, info);
 
         // Backdrop
-        return updated || UpdateImage(ImageType.Backdrop, item, info);
+        updated |= UpdateImage(ImageType.Backdrop, item, info);
+
+        return updated;
     }
 
     private static bool UpdateImage(ImageType imageType, BaseItem item, ProgramInfo info)
@@ -689,7 +672,7 @@ public class GuideManager : IGuideManager
         var newImagePath = imageType switch
         {
             ImageType.Primary => info.ImagePath,
-            _ => string.Empty
+            _ => null
         };
         var newImageUrl = imageType switch
         {
@@ -697,12 +680,12 @@ public class GuideManager : IGuideManager
             ImageType.Logo => info.LogoImageUrl,
             ImageType.Primary => info.ImageUrl,
             ImageType.Thumb => info.ThumbImageUrl,
-            _ => string.Empty
+            _ => null
         };
 
-        var differentImage = newImageUrl?.Equals(currentImagePath, StringComparison.OrdinalIgnoreCase) == false
-                                || newImagePath?.Equals(currentImagePath, StringComparison.OrdinalIgnoreCase) == false;
-        if (!differentImage)
+        var sameImage = (currentImagePath?.Equals(newImageUrl, StringComparison.OrdinalIgnoreCase) ?? false)
+                                || (currentImagePath?.Equals(newImagePath, StringComparison.OrdinalIgnoreCase) ?? false);
+        if (sameImage)
         {
             return false;
         }
@@ -757,6 +740,7 @@ public class GuideManager : IGuideManager
                     var imageInfo = program.ImageInfos[i];
                     if (!imageInfo.IsLocalFile)
                     {
+                        _logger.LogDebug("Caching image locally: {Url}", imageInfo.Path);
                         try
                         {
                             program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(

+ 9 - 9
tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs → tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedCollectionModelBinderTests.cs

@@ -12,7 +12,7 @@ using Xunit;
 
 namespace Jellyfin.Api.Tests.ModelBinders
 {
-    public sealed class CommaDelimitedArrayModelBinderTests
+    public sealed class CommaDelimitedCollectionModelBinderTests
     {
         [Fact]
         public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedStringArrayQuery()
@@ -22,7 +22,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString = "lol,xd";
             var queryParamType = typeof(string[]);
 
-            var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>());
+            var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger<CommaDelimitedCollectionModelBinder>());
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
                     new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
@@ -47,7 +47,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString = "42,0";
             var queryParamType = typeof(int[]);
 
-            var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>());
+            var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger<CommaDelimitedCollectionModelBinder>());
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
                     new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
@@ -72,7 +72,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString = "How,Much";
             var queryParamType = typeof(TestType[]);
 
-            var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>());
+            var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger<CommaDelimitedCollectionModelBinder>());
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
                     new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
@@ -97,7 +97,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString = "How,,Much";
             var queryParamType = typeof(TestType[]);
 
-            var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>());
+            var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger<CommaDelimitedCollectionModelBinder>());
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
                     new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
@@ -123,7 +123,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString2 = "Much";
             var queryParamType = typeof(TestType[]);
 
-            var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>());
+            var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger<CommaDelimitedCollectionModelBinder>());
 
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
@@ -151,7 +151,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             IReadOnlyList<TestType> queryParamValues = Array.Empty<TestType>();
             var queryParamType = typeof(TestType[]);
 
-            var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>());
+            var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger<CommaDelimitedCollectionModelBinder>());
 
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
@@ -179,7 +179,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString = "🔥,😢";
             var queryParamType = typeof(IReadOnlyList<TestType>);
 
-            var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>());
+            var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger<CommaDelimitedCollectionModelBinder>());
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
                     new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
@@ -205,7 +205,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString2 = "😱";
             var queryParamType = typeof(IReadOnlyList<TestType>);
 
-            var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>());
+            var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger<CommaDelimitedCollectionModelBinder>());
 
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),

+ 9 - 9
tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs → tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedCollectionModelBinderTests.cs

@@ -12,7 +12,7 @@ using Xunit;
 
 namespace Jellyfin.Api.Tests.ModelBinders
 {
-    public sealed class PipeDelimitedArrayModelBinderTests
+    public sealed class PipeDelimitedCollectionModelBinderTests
     {
         [Fact]
         public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedStringArrayQuery()
@@ -22,7 +22,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString = "lol|xd";
             var queryParamType = typeof(string[]);
 
-            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger<PipeDelimitedCollectionModelBinder>());
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
                     new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
@@ -47,7 +47,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString = "42|0";
             var queryParamType = typeof(int[]);
 
-            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger<PipeDelimitedCollectionModelBinder>());
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
                     new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
@@ -72,7 +72,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString = "How|Much";
             var queryParamType = typeof(TestType[]);
 
-            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger<PipeDelimitedCollectionModelBinder>());
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
                     new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
@@ -97,7 +97,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString = "How||Much";
             var queryParamType = typeof(TestType[]);
 
-            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger<PipeDelimitedCollectionModelBinder>());
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
                     new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
@@ -123,7 +123,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString2 = "Much";
             var queryParamType = typeof(TestType[]);
 
-            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger<PipeDelimitedCollectionModelBinder>());
 
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
@@ -151,7 +151,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             IReadOnlyList<TestType> queryParamValues = Array.Empty<TestType>();
             var queryParamType = typeof(TestType[]);
 
-            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger<PipeDelimitedCollectionModelBinder>());
 
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
@@ -179,7 +179,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString = "🔥|😢";
             var queryParamType = typeof(IReadOnlyList<TestType>);
 
-            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger<PipeDelimitedCollectionModelBinder>());
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),
                     new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
@@ -205,7 +205,7 @@ namespace Jellyfin.Api.Tests.ModelBinders
             var queryParamString2 = "😱";
             var queryParamType = typeof(IReadOnlyList<TestType>);
 
-            var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+            var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger<PipeDelimitedCollectionModelBinder>());
 
             var valueProvider = new QueryStringValueProvider(
                     new BindingSource(string.Empty, string.Empty, false, false),

+ 74 - 1
tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs → tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedCollectionTests.cs

@@ -1,4 +1,7 @@
 using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
 using System.Text.Json;
 using System.Text.Json.Serialization;
 using Jellyfin.Extensions.Tests.Json.Models;
@@ -7,7 +10,7 @@ using Xunit;
 
 namespace Jellyfin.Extensions.Tests.Json.Converters
 {
-    public class JsonCommaDelimitedArrayTests
+    public class JsonCommaDelimitedCollectionTests
     {
         private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions()
         {
@@ -36,6 +39,29 @@ namespace Jellyfin.Extensions.Tests.Json.Converters
             Assert.Equal(desiredValue.Value, value?.Value);
         }
 
+        [Fact]
+        public void Deserialize_EmptyList_Success()
+        {
+            var desiredValue = new GenericBodyListModel<string>
+            {
+                Value = []
+            };
+
+            Assert.Throws<InvalidOperationException>(() => JsonSerializer.Deserialize<GenericBodyListModel<string>>(@"{ ""Value"": """" }", _jsonOptions));
+        }
+
+        [Fact]
+        public void Deserialize_EmptyIReadOnlyList_Success()
+        {
+            var desiredValue = new GenericBodyIReadOnlyListModel<string>
+            {
+                Value = []
+            };
+
+            var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<string>>(@"{ ""Value"": """" }", _jsonOptions);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+
         [Fact]
         public void Deserialize_String_Valid_Success()
         {
@@ -48,6 +74,17 @@ namespace Jellyfin.Extensions.Tests.Json.Converters
             Assert.Equal(desiredValue.Value, value?.Value);
         }
 
+        [Fact]
+        public void Deserialize_StringList_Valid_Success()
+        {
+            var desiredValue = new GenericBodyListModel<string>
+            {
+                Value = ["a", "b", "c"]
+            };
+
+            Assert.Throws<InvalidOperationException>(() => JsonSerializer.Deserialize<GenericBodyListModel<string>>(@"{ ""Value"": ""a,b,c"" }", _jsonOptions));
+        }
+
         [Fact]
         public void Deserialize_String_Space_Valid_Success()
         {
@@ -131,5 +168,41 @@ namespace Jellyfin.Extensions.Tests.Json.Converters
             var value = JsonSerializer.Deserialize<GenericBodyArrayModel<GeneralCommandType>>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", _jsonOptions);
             Assert.Equal(desiredValue.Value, value?.Value);
         }
+
+        [Fact]
+        public void Serialize_GenericCommandType_ReadOnlyArray_Valid_Success()
+        {
+            var valueToSerialize = new GenericBodyIReadOnlyCollectionModel<GeneralCommandType>
+            {
+                Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }.AsReadOnly()
+            };
+
+            string value = JsonSerializer.Serialize<GenericBodyIReadOnlyCollectionModel<GeneralCommandType>>(valueToSerialize, _jsonOptions);
+            Assert.Equal(@"{""Value"":[""MoveUp"",""MoveDown""]}", value);
+        }
+
+        [Fact]
+        public void Serialize_GenericCommandType_ImmutableArrayArray_Valid_Success()
+        {
+            var valueToSerialize = new GenericBodyIReadOnlyCollectionModel<GeneralCommandType>
+            {
+                Value = ImmutableArray.Create(new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown })
+            };
+
+            string value = JsonSerializer.Serialize<GenericBodyIReadOnlyCollectionModel<GeneralCommandType>>(valueToSerialize, _jsonOptions);
+            Assert.Equal(@"{""Value"":[""MoveUp"",""MoveDown""]}", value);
+        }
+
+        [Fact]
+        public void Serialize_GenericCommandType_List_Valid_Success()
+        {
+            var valueToSerialize = new GenericBodyIReadOnlyListModel<GeneralCommandType>
+            {
+                Value = new List<GeneralCommandType> { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+            };
+
+            string value = JsonSerializer.Serialize<GenericBodyIReadOnlyListModel<GeneralCommandType>>(valueToSerialize, _jsonOptions);
+            Assert.Equal(@"{""Value"":[""MoveUp"",""MoveDown""]}", value);
+        }
     }
 }

+ 13 - 0
tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs

@@ -1,3 +1,4 @@
+using System.Collections.Generic;
 using System.Text.Json;
 using System.Text.Json.Serialization;
 using Jellyfin.Extensions.Tests.Json.Models;
@@ -87,5 +88,17 @@ namespace Jellyfin.Extensions.Tests.Json.Converters
             var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<GeneralCommandType>>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", _jsonOptions);
             Assert.Equal(desiredValue.Value, value?.Value);
         }
+
+        [Fact]
+        public void Serialize_GenericCommandType_IReadOnlyList_Valid_Success()
+        {
+            var valueToSerialize = new GenericBodyIReadOnlyListModel<GeneralCommandType>
+            {
+                Value = new List<GeneralCommandType> { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+            };
+
+            string value = JsonSerializer.Serialize<GenericBodyIReadOnlyListModel<GeneralCommandType>>(valueToSerialize, _jsonOptions);
+            Assert.Equal(@"{""Value"":[""MoveUp"",""MoveDown""]}", value);
+        }
     }
 }

+ 1 - 1
tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs

@@ -14,7 +14,7 @@ namespace Jellyfin.Extensions.Tests.Json.Models
         /// Gets or sets the value.
         /// </summary>
         [SuppressMessage("Microsoft.Performance", "CA1819:Properties should not return arrays", MessageId = "Value", Justification = "Imported from ServiceStack")]
-        [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+        [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
         public T[] Value { get; set; } = default!;
     }
 }

+ 19 - 0
tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyCollectionModel.cs

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

+ 1 - 1
tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs

@@ -13,7 +13,7 @@ namespace Jellyfin.Extensions.Tests.Json.Models
         /// <summary>
         /// Gets or sets the value.
         /// </summary>
-        [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+        [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
         public IReadOnlyList<T> Value { get; set; } = default!;
     }
 }

+ 22 - 0
tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyListModel.cs

@@ -0,0 +1,22 @@
+#pragma warning disable CA1002 // Do not expose generic lists
+#pragma warning disable CA2227 // Collection properties should be read only
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using Jellyfin.Extensions.Json.Converters;
+
+namespace Jellyfin.Extensions.Tests.Json.Models
+{
+    /// <summary>
+    /// The generic body <c>List</c> model.
+    /// </summary>
+    /// <typeparam name="T">The value type.</typeparam>
+    public sealed class GenericBodyListModel<T>
+    {
+        /// <summary>
+        /// Gets or sets the value.
+        /// </summary>
+        [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))]
+        public List<T> Value { get; set; } = default!;
+    }
+}

+ 4 - 4
tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs

@@ -65,7 +65,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
             Assert.True(res.VideoStream.IsDefault);
             Assert.False(res.VideoStream.IsExternal);
             Assert.False(res.VideoStream.IsForced);
-            Assert.False(res.VideoStream.IsHearingImpaired.GetValueOrDefault());
+            Assert.False(res.VideoStream.IsHearingImpaired);
             Assert.False(res.VideoStream.IsInterlaced);
             Assert.False(res.VideoStream.IsTextSubtitleStream);
             Assert.Equal(13d, res.VideoStream.Level);
@@ -152,19 +152,19 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
             Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[3].Type);
             Assert.Equal("DVDSUB", res.MediaStreams[3].Codec);
             Assert.Null(res.MediaStreams[3].Title);
-            Assert.False(res.MediaStreams[3].IsHearingImpaired.GetValueOrDefault());
+            Assert.False(res.MediaStreams[3].IsHearingImpaired);
 
             Assert.Equal("eng", res.MediaStreams[4].Language);
             Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[4].Type);
             Assert.Equal("mov_text", res.MediaStreams[4].Codec);
             Assert.Null(res.MediaStreams[4].Title);
-            Assert.True(res.MediaStreams[4].IsHearingImpaired.GetValueOrDefault());
+            Assert.True(res.MediaStreams[4].IsHearingImpaired);
 
             Assert.Equal("eng", res.MediaStreams[5].Language);
             Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[5].Type);
             Assert.Equal("mov_text", res.MediaStreams[5].Codec);
             Assert.Equal("Commentary", res.MediaStreams[5].Title);
-            Assert.False(res.MediaStreams[5].IsHearingImpaired.GetValueOrDefault());
+            Assert.False(res.MediaStreams[5].IsHearingImpaired);
         }
 
         [Fact]