浏览代码

Merge branch 'master' into network-rewrite

Shadowghost 2 年之前
父节点
当前提交
3a91c37283
共有 100 个文件被更改,包括 8247 次插入9234 次删除
  1. 5 5
      .github/workflows/automation.yml
  2. 3 3
      .github/workflows/codeql-analysis.yml
  3. 5 5
      .github/workflows/commands.yml
  4. 3 3
      .github/workflows/openapi.yml
  5. 0 3
      .npmrc
  6. 2 0
      CONTRIBUTORS.md
  7. 90 0
      Directory.Packages.props
  8. 1 1
      Dockerfile
  9. 5 5
      Emby.Dlna/Emby.Dlna.csproj
  10. 2 1
      Emby.Dlna/Main/DlnaEntryPoint.cs
  11. 5 5
      Emby.Naming/Emby.Naming.csproj
  12. 0 123
      Emby.Notifications/CoreNotificationTypes.cs
  13. 0 35
      Emby.Notifications/Emby.Notifications.csproj
  14. 0 23
      Emby.Notifications/NotificationConfigurationFactory.cs
  15. 0 314
      Emby.Notifications/NotificationEntryPoint.cs
  16. 0 224
      Emby.Notifications/NotificationManager.cs
  17. 0 21
      Emby.Notifications/Properties/AssemblyInfo.cs
  18. 5 5
      Emby.Photos/Emby.Photos.csproj
  19. 17 35
      Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs
  20. 13 87
      Emby.Server.Implementations/ApplicationHost.cs
  21. 5 8
      Emby.Server.Implementations/Channels/ChannelManager.cs
  22. 1 3
      Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs
  23. 21 0
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  24. 15 16
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  25. 0 2
      Emby.Server.Implementations/Images/BaseFolderImageProvider.cs
  26. 0 2
      Emby.Server.Implementations/Images/FolderImageProvider.cs
  27. 0 2
      Emby.Server.Implementations/Images/GenreImageProvider.cs
  28. 12 11
      Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs
  29. 3 1
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
  30. 125 2
      Emby.Server.Implementations/Localization/Core/be.json
  31. 2 1
      Emby.Server.Implementations/Localization/Core/es_419.json
  32. 2 1
      Emby.Server.Implementations/Localization/Core/fa.json
  33. 8 4
      Emby.Server.Implementations/Localization/Core/gsw.json
  34. 6 1
      Emby.Server.Implementations/Localization/Core/pr.json
  35. 1 1
      Emby.Server.Implementations/Localization/Core/sv.json
  36. 38 15
      Emby.Server.Implementations/Plugins/PluginManager.cs
  37. 19 20
      Jellyfin.Api/Attributes/AcceptsFileAttribute.cs
  38. 11 12
      Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs
  39. 19 20
      Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs
  40. 19 20
      Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs
  41. 7 8
      Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs
  42. 11 12
      Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs
  43. 19 20
      Jellyfin.Api/Attributes/ProducesFileAttribute.cs
  44. 11 12
      Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs
  45. 11 12
      Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs
  46. 11 12
      Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs
  47. 2 1
      Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs
  48. 0 113
      Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
  49. 48 5
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
  50. 13 0
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs
  51. 0 44
      Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs
  52. 0 11
      Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs
  53. 0 56
      Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs
  54. 0 11
      Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs
  55. 0 56
      Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs
  56. 0 11
      Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs
  57. 0 11
      Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs
  58. 28 18
      Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs
  59. 25 0
      Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupRequirement.cs
  60. 0 44
      Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs
  61. 0 11
      Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs
  62. 19 8
      Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs
  63. 1 1
      Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs
  64. 0 44
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
  65. 0 11
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs
  66. 0 45
      Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs
  67. 0 11
      Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs
  68. 8 38
      Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
  69. 3 3
      Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs
  70. 42 0
      Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs
  71. 26 0
      Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs
  72. 26 27
      Jellyfin.Api/BaseJellyfinApiController.cs
  73. 8 9
      Jellyfin.Api/Constants/AuthenticationSchemes.cs
  74. 32 33
      Jellyfin.Api/Constants/InternalClaimTypes.cs
  75. 84 75
      Jellyfin.Api/Constants/Policies.cs
  76. 16 17
      Jellyfin.Api/Constants/UserRoles.cs
  77. 38 39
      Jellyfin.Api/Controllers/ActivityLogController.cs
  78. 54 55
      Jellyfin.Api/Controllers/ApiKeyController.cs
  79. 404 406
      Jellyfin.Api/Controllers/ArtistsController.cs
  80. 339 340
      Jellyfin.Api/Controllers/AudioController.cs
  81. 42 43
      Jellyfin.Api/Controllers/BrandingController.cs
  82. 207 209
      Jellyfin.Api/Controllers/ChannelsController.cs
  83. 53 56
      Jellyfin.Api/Controllers/ClientLogController.cs
  84. 83 84
      Jellyfin.Api/Controllers/CollectionController.cs
  85. 104 105
      Jellyfin.Api/Controllers/ConfigurationController.cs
  86. 78 80
      Jellyfin.Api/Controllers/DashboardController.cs
  87. 104 105
      Jellyfin.Api/Controllers/DevicesController.cs
  88. 170 172
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  89. 103 104
      Jellyfin.Api/Controllers/DlnaController.cs
  90. 275 276
      Jellyfin.Api/Controllers/DlnaServerController.cs
  91. 1829 1831
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  92. 142 143
      Jellyfin.Api/Controllers/EnvironmentController.cs
  93. 179 181
      Jellyfin.Api/Controllers/FilterController.cs
  94. 161 163
      Jellyfin.Api/Controllers/GenresController.cs
  95. 147 149
      Jellyfin.Api/Controllers/HlsSegmentController.cs
  96. 1874 1910
      Jellyfin.Api/Controllers/ImageController.cs
  97. 322 324
      Jellyfin.Api/Controllers/InstantMixController.cs
  98. 231 233
      Jellyfin.Api/Controllers/ItemLookupController.cs
  99. 62 63
      Jellyfin.Api/Controllers/ItemRefreshController.cs
  100. 332 333
      Jellyfin.Api/Controllers/ItemUpdateController.cs

+ 5 - 5
.github/workflows/automation.yml

@@ -27,7 +27,7 @@ jobs:
     if: ${{ github.repository == 'jellyfin/jellyfin' }}
     steps:
       - name: Remove from 'Current Release' project
-        uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2
+        uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3
         if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
         continue-on-error: true
         with:
@@ -36,7 +36,7 @@ jobs:
           repo-token: ${{ secrets.JF_BOT_TOKEN }}
 
       - name: Add to 'Release Next' project
-        uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2
+        uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3
         if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened'
         continue-on-error: true
         with:
@@ -45,7 +45,7 @@ jobs:
           repo-token: ${{ secrets.JF_BOT_TOKEN }}
 
       - name: Add to 'Current Release' project
-        uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2
+        uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3
         if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
         continue-on-error: true
         with:
@@ -59,7 +59,7 @@ jobs:
         run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)"
 
       - name: Move issue to needs triage
-        uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2
+        uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3
         if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1
         continue-on-error: true
         with:
@@ -68,7 +68,7 @@ jobs:
           repo-token: ${{ secrets.JF_BOT_TOKEN }}
 
       - name: Add issue to triage project
-        uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2
+        uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3
         if: github.event.issue.pull_request == '' && github.event.action == 'opened'
         continue-on-error: true
         with:

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

@@ -27,11 +27,11 @@ jobs:
         dotnet-version: '7.0.x'
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@a34ca99b4610d924e04c68db79e503e1f79f9f02 # v2
+      uses: github/codeql-action/init@17573ee1cc1b9d061760f3a006fc4aac4f944fd5 # v2
       with:
         languages: ${{ matrix.language }}
         queries: +security-extended
     - name: Autobuild
-      uses: github/codeql-action/autobuild@a34ca99b4610d924e04c68db79e503e1f79f9f02 # v2
+      uses: github/codeql-action/autobuild@17573ee1cc1b9d061760f3a006fc4aac4f944fd5 # v2
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@a34ca99b4610d924e04c68db79e503e1f79f9f02 # v2
+      uses: github/codeql-action/analyze@17573ee1cc1b9d061760f3a006fc4aac4f944fd5 # v2

+ 5 - 5
.github/workflows/commands.yml

@@ -17,7 +17,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Notify as seen
-        uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2
+        uses: peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d # v2
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
           comment-id: ${{ github.event.comment.id }}
@@ -43,7 +43,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Notify as seen
-        uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2
+        uses: peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d # v2
         if: ${{ github.event.comment != null }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
@@ -58,7 +58,7 @@ jobs:
 
       - name: Notify as running
         id: comment_running
-        uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2
+        uses: peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d # v2
         if: ${{ github.event.comment != null }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
@@ -93,7 +93,7 @@ jobs:
           exit ${retcode}
 
       - name: Notify with result success
-        uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2
+        uses: peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d # v2
         if: ${{ github.event.comment != null && success() }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
@@ -108,7 +108,7 @@ jobs:
           reactions: hooray
 
       - name: Notify with result failure
-        uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2
+        uses: peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d # v2
         if: ${{ github.event.comment != null && failure() }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}

+ 3 - 3
.github/workflows/openapi.yml

@@ -103,14 +103,14 @@ jobs:
           body="${body//$'\r'/'%0D'}"
           echo ::set-output name=body::$body
       - name: Find difference comment
-        uses: peter-evans/find-comment@81e2da3af01c92f83cb927cf3ace0e085617c556 # v2
+        uses: peter-evans/find-comment@85a676a52594b4481e0532825a2d8906ef96dac2 # v2
         id: find-comment
         with:
           issue-number: ${{ github.event.pull_request.number }}
           direction: last
           body-includes: openapi-diff-workflow-comment
       - name: Reply or edit difference comment (changed)
-        uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2
+        uses: peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d # v2
         if: ${{ steps.read-diff.outputs.body != '' }}
         with:
           issue-number: ${{ github.event.pull_request.number }}
@@ -125,7 +125,7 @@ jobs:
 
             </details>
       - name: Edit difference comment (unchanged)
-        uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2
+        uses: peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d # v2
         if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
         with:
           issue-number: ${{ github.event.pull_request.number }}

+ 0 - 3
.npmrc

@@ -1,3 +0,0 @@
-registry=https://registry.npmjs.org/
-@jellyfin:registry=https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/npm/registry/
-always-auth=true

+ 2 - 0
CONTRIBUTORS.md

@@ -58,6 +58,7 @@
  - [HelloWorld017](https://github.com/HelloWorld017)
  - [ikomhoog](https://github.com/ikomhoog)
  - [jftuga](https://github.com/jftuga)
+ - [jmshrv](https://github.com/jmshrv)
  - [joern-h](https://github.com/joern-h)
  - [joshuaboniface](https://github.com/joshuaboniface)
  - [JustAMan](https://github.com/JustAMan)
@@ -231,3 +232,4 @@
  - [Matthew Jones](https://github.com/matthew-jones-uk)
  - [Jakob Kukla](https://github.com/jakobkukla)
  - [Utku Özdemir](https://github.com/utkuozdemir)
+ - [JPUC1143](https://github.com/Jpuc1143/)

+ 90 - 0
Directory.Packages.props

@@ -0,0 +1,90 @@
+<Project>
+  <PropertyGroup>
+    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
+  </PropertyGroup>
+
+  <!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
+
+  <ItemGroup Label="Package Dependencies">
+    <PackageVersion Include="AutoFixture.AutoMoq" Version="4.17.0" />
+    <PackageVersion Include="AutoFixture.Xunit2" Version="4.17.0" />
+    <PackageVersion Include="AutoFixture" Version="4.17.0" />
+    <PackageVersion Include="BDInfo" Version="0.7.6.2" />
+    <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.2.0" />
+    <PackageVersion Include="BlurHashSharp" Version="1.2.0" />
+    <PackageVersion Include="CommandLineParser" Version="2.9.1" />
+    <PackageVersion Include="coverlet.collector" Version="3.2.0" />
+    <PackageVersion Include="Diacritics" Version="3.3.14" />
+    <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
+    <PackageVersion Include="DotNet.Glob" Version="3.1.3" />
+    <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.8.3" />
+    <PackageVersion Include="FsCheck.Xunit" Version="2.16.5" />
+    <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
+    <PackageVersion Include="libse" Version="3.6.10" />
+    <PackageVersion Include="LrcParser" Version="2022.529.1" />
+    <PackageVersion Include="MetaBrainz.MusicBrainz" Version="5.0.0" />
+    <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
+    <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
+    <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
+    <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
+    <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
+    <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
+    <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
+    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" />
+    <PackageVersion Include="Microsoft.Extensions.Http" Version="7.0.0" />
+    <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
+    <PackageVersion Include="Microsoft.Extensions.Logging" Version="7.0.0" />
+    <PackageVersion Include="Microsoft.Extensions.Options" Version="7.0.1" />
+    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
+    <PackageVersion Include="Microsoft.SourceLink.GitHub" Version="1.1.1" />
+    <PackageVersion Include="MimeTypes" Version="2.4.0" />
+    <PackageVersion Include="Mono.Nat" Version="3.0.4" />
+    <PackageVersion Include="Moq" Version="4.18.4" />
+    <PackageVersion Include="NEbml" Version="0.11.0" />
+    <PackageVersion Include="Newtonsoft.Json" Version="13.0.2" />
+    <PackageVersion Include="PlaylistsNET" Version="1.3.1" />
+    <PackageVersion Include="prometheus-net.AspNetCore" Version="7.0.0" />
+    <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.0" />
+    <PackageVersion Include="prometheus-net" Version="7.0.0" />
+    <PackageVersion Include="Serilog.AspNetCore" Version="6.1.0" />
+    <PackageVersion Include="Serilog.Enrichers.Thread" Version="3.1.0" />
+    <PackageVersion Include="Serilog.Settings.Configuration" Version="3.4.0" />
+    <PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" />
+    <PackageVersion Include="Serilog.Sinks.Console" Version="4.1.0" />
+    <PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
+    <PackageVersion Include="Serilog.Sinks.Graylog" Version="2.3.0" />
+    <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
+    <PackageVersion Include="SharpFuzz" Version="2.0.1" />
+    <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.3" />
+    <PackageVersion Include="SkiaSharp.Svg" Version="1.60.0" />
+    <PackageVersion Include="SkiaSharp" Version="2.88.3" />
+    <PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
+    <PackageVersion Include="SQLitePCL.pretty.netstandard" Version="3.1.0" />
+    <PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.4" />
+    <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.435" />
+    <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.4.0" />
+    <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="7.0.0" />
+    <PackageVersion Include="System.Text.Json" Version="7.0.2" />
+    <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="7.0.0" />
+    <PackageVersion Include="TagLibSharp" Version="2.3.0" />
+    <PackageVersion Include="TMDbLib" Version="2.0.0" />
+    <PackageVersion Include="UTF.Unknown" Version="2.5.1" />
+    <PackageVersion Include="Xunit.Priority" Version="1.1.6" />
+    <PackageVersion Include="xunit.runner.visualstudio" Version="2.4.5" />
+    <PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" />
+    <PackageVersion Include="xunit" Version="2.4.2" />
+  </ItemGroup>
+</Project>

+ 1 - 1
Dockerfile

@@ -37,7 +37,7 @@ RUN apt-get update \
  && apt-get update \
  && apt-get install --no-install-recommends --no-install-suggests -y \
    mesa-va-drivers \
-   jellyfin-ffmpeg \
+   jellyfin-ffmpeg5 \
    openssl \
    locales \
 # Intel VAAPI Tone mapping dependencies:

+ 5 - 5
Emby.Dlna/Emby.Dlna.csproj

@@ -28,13 +28,13 @@
 
   <!-- Code Analyzers-->
   <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
+    <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
     </PackageReference>
-    <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
-    <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" />
-    <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
+    <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
+    <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
+    <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
   </ItemGroup>
 
   <ItemGroup>
@@ -80,7 +80,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
+    <PackageReference Include="Microsoft.Extensions.Http" />
   </ItemGroup>
 
 </Project>

+ 2 - 1
Emby.Dlna/Main/DlnaEntryPoint.cs

@@ -7,6 +7,7 @@ using System.Globalization;
 using System.Linq;
 using System.Net.Http;
 using System.Net.Sockets;
+using System.Runtime.InteropServices;
 using System.Threading.Tasks;
 using Emby.Dlna.PlayTo;
 using Emby.Dlna.Ssdp;
@@ -261,7 +262,7 @@ namespace Emby.Dlna.Main
             {
                 _publisher = new SsdpDevicePublisher(
                     _communicationsServer,
-                    MediaBrowser.Common.System.OperatingSystem.Name,
+                    Environment.OSVersion.Platform.ToString(),
                     Environment.OSVersion.VersionString,
                     _config.GetDlnaConfiguration().SendOnlyMatchedHost)
                 {

+ 5 - 5
Emby.Naming/Emby.Naming.csproj

@@ -42,18 +42,18 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
+    <PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
   </ItemGroup>
 
   <!-- Code Analyzers-->
   <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
+    <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
     </PackageReference>
-    <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
-    <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" />
-    <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
+    <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
+    <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
+    <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
   </ItemGroup>
 
 </Project>

+ 0 - 123
Emby.Notifications/CoreNotificationTypes.cs

@@ -1,123 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using MediaBrowser.Controller.Notifications;
-using MediaBrowser.Model.Globalization;
-using MediaBrowser.Model.Notifications;
-
-namespace Emby.Notifications
-{
-    public class CoreNotificationTypes : INotificationTypeFactory
-    {
-        private readonly ILocalizationManager _localization;
-
-        public CoreNotificationTypes(ILocalizationManager localization)
-        {
-            _localization = localization;
-        }
-
-        public IEnumerable<NotificationTypeInfo> GetNotificationTypes()
-        {
-            var knownTypes = new NotificationTypeInfo[]
-            {
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.ApplicationUpdateInstalled)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.InstallationFailed)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.PluginInstalled)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.PluginError)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.PluginUninstalled)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.PluginUpdateInstalled)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.ServerRestartRequired)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.TaskFailed)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.NewLibraryContent)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.AudioPlayback)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.VideoPlayback)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.AudioPlaybackStopped)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.VideoPlaybackStopped)
-                },
-                new NotificationTypeInfo
-                {
-                     Type = nameof(NotificationType.UserLockedOut)
-                },
-                new NotificationTypeInfo
-                {
-                    Type = nameof(NotificationType.ApplicationUpdateAvailable)
-                }
-            };
-
-            foreach (var type in knownTypes)
-            {
-                Update(type);
-            }
-
-            var systemName = _localization.GetLocalizedString("System");
-
-            return knownTypes.OrderByDescending(i => string.Equals(i.Category, systemName, StringComparison.OrdinalIgnoreCase))
-                .ThenBy(i => i.Category)
-                .ThenBy(i => i.Name);
-        }
-
-        private void Update(NotificationTypeInfo note)
-        {
-            note.Name = _localization.GetLocalizedString("NotificationOption" + note.Type);
-
-            note.IsBasedOnUserEvent = note.Type.IndexOf("Playback", StringComparison.OrdinalIgnoreCase) != -1;
-
-            if (note.Type.IndexOf("Playback", StringComparison.OrdinalIgnoreCase) != -1)
-            {
-                note.Category = _localization.GetLocalizedString("User");
-            }
-            else if (note.Type.IndexOf("Plugin", StringComparison.OrdinalIgnoreCase) != -1)
-            {
-                note.Category = _localization.GetLocalizedString("Plugin");
-            }
-            else if (note.Type.IndexOf("UserLockedOut", StringComparison.OrdinalIgnoreCase) != -1)
-            {
-                note.Category = _localization.GetLocalizedString("User");
-            }
-            else
-            {
-                note.Category = _localization.GetLocalizedString("System");
-            }
-        }
-    }
-}

+ 0 - 35
Emby.Notifications/Emby.Notifications.csproj

@@ -1,35 +0,0 @@
-<Project Sdk="Microsoft.NET.Sdk">
-
-  <!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
-  <PropertyGroup>
-    <ProjectGuid>{2E030C33-6923-4530-9E54-FA29FA6AD1A9}</ProjectGuid>
-  </PropertyGroup>
-
-  <PropertyGroup>
-    <TargetFramework>net7.0</TargetFramework>
-    <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
-    <GenerateDocumentationFile>true</GenerateDocumentationFile>
-  </PropertyGroup>
-
-  <ItemGroup>
-    <Compile Include="..\SharedVersion.cs" />
-  </ItemGroup>
-
-  <ItemGroup>
-    <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
-    <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
-    <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
-  </ItemGroup>
-
-  <!-- Code analyzers-->
-  <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
-      <PrivateAssets>all</PrivateAssets>
-      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
-    </PackageReference>
-    <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
-    <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" />
-    <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
-  </ItemGroup>
-
-</Project>

+ 0 - 23
Emby.Notifications/NotificationConfigurationFactory.cs

@@ -1,23 +0,0 @@
-#pragma warning disable CS1591
-
-using System.Collections.Generic;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Model.Notifications;
-
-namespace Emby.Notifications
-{
-    public class NotificationConfigurationFactory : IConfigurationFactory
-    {
-        public IEnumerable<ConfigurationStore> GetConfigurations()
-        {
-            return new ConfigurationStore[]
-            {
-                new ConfigurationStore
-                {
-                    Key = "notifications",
-                    ConfigurationType = typeof(NotificationOptions)
-                }
-            };
-        }
-    }
-}

+ 0 - 314
Emby.Notifications/NotificationEntryPoint.cs

@@ -1,314 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Jellyfin.Data.Events;
-using Jellyfin.Extensions;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Controller;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Audio;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Notifications;
-using MediaBrowser.Controller.Plugins;
-using MediaBrowser.Model.Activity;
-using MediaBrowser.Model.Globalization;
-using MediaBrowser.Model.Notifications;
-using Microsoft.Extensions.Logging;
-
-namespace Emby.Notifications
-{
-    /// <summary>
-    /// Creates notifications for various system events.
-    /// </summary>
-    public class NotificationEntryPoint : IServerEntryPoint
-    {
-        private readonly ILogger<NotificationEntryPoint> _logger;
-        private readonly IActivityManager _activityManager;
-        private readonly ILocalizationManager _localization;
-        private readonly INotificationManager _notificationManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IServerApplicationHost _appHost;
-        private readonly IConfigurationManager _config;
-
-        private readonly object _libraryChangedSyncLock = new object();
-        private readonly List<BaseItem> _itemsAdded = new List<BaseItem>();
-
-        private Timer? _libraryUpdateTimer;
-
-        private string[] _coreNotificationTypes;
-
-        private bool _disposed = false;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="NotificationEntryPoint" /> class.
-        /// </summary>
-        /// <param name="logger">The logger.</param>
-        /// <param name="activityManager">The activity manager.</param>
-        /// <param name="localization">The localization manager.</param>
-        /// <param name="notificationManager">The notification manager.</param>
-        /// <param name="libraryManager">The library manager.</param>
-        /// <param name="appHost">The application host.</param>
-        /// <param name="config">The configuration manager.</param>
-        public NotificationEntryPoint(
-            ILogger<NotificationEntryPoint> logger,
-            IActivityManager activityManager,
-            ILocalizationManager localization,
-            INotificationManager notificationManager,
-            ILibraryManager libraryManager,
-            IServerApplicationHost appHost,
-            IConfigurationManager config)
-        {
-            _logger = logger;
-            _activityManager = activityManager;
-            _localization = localization;
-            _notificationManager = notificationManager;
-            _libraryManager = libraryManager;
-            _appHost = appHost;
-            _config = config;
-
-            _coreNotificationTypes = new CoreNotificationTypes(localization).GetNotificationTypes().Select(i => i.Type).ToArray();
-        }
-
-        /// <inheritdoc />
-        public Task RunAsync()
-        {
-            _libraryManager.ItemAdded += OnLibraryManagerItemAdded;
-            _appHost.HasPendingRestartChanged += OnAppHostHasPendingRestartChanged;
-            _activityManager.EntryCreated += OnActivityManagerEntryCreated;
-
-            return Task.CompletedTask;
-        }
-
-        private async void OnAppHostHasPendingRestartChanged(object? sender, EventArgs e)
-        {
-            var type = NotificationType.ServerRestartRequired.ToString();
-
-            var notification = new NotificationRequest
-            {
-                NotificationType = type,
-                Name = string.Format(
-                    CultureInfo.InvariantCulture,
-                    _localization.GetLocalizedString("ServerNameNeedsToBeRestarted"),
-                    _appHost.Name)
-            };
-
-            await SendNotification(notification, null).ConfigureAwait(false);
-        }
-
-        private async void OnActivityManagerEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e)
-        {
-            var entry = e.Argument;
-
-            var type = entry.Type;
-
-            if (string.IsNullOrEmpty(type) || !_coreNotificationTypes.Contains(type, StringComparison.OrdinalIgnoreCase))
-            {
-                return;
-            }
-
-            var userId = e.Argument.UserId;
-
-            if (!userId.Equals(default) && !GetOptions().IsEnabledToMonitorUser(type, userId))
-            {
-                return;
-            }
-
-            var notification = new NotificationRequest
-            {
-                NotificationType = type,
-                Name = entry.Name,
-                Description = entry.Overview
-            };
-
-            await SendNotification(notification, null).ConfigureAwait(false);
-        }
-
-        private NotificationOptions GetOptions()
-        {
-            return _config.GetConfiguration<NotificationOptions>("notifications");
-        }
-
-        private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e)
-        {
-            if (!FilterItem(e.Item))
-            {
-                return;
-            }
-
-            lock (_libraryChangedSyncLock)
-            {
-                if (_libraryUpdateTimer is null)
-                {
-                    _libraryUpdateTimer = new Timer(
-                        LibraryUpdateTimerCallback,
-                        null,
-                        5000,
-                        Timeout.Infinite);
-                }
-                else
-                {
-                    _libraryUpdateTimer.Change(5000, Timeout.Infinite);
-                }
-
-                _itemsAdded.Add(e.Item);
-            }
-        }
-
-        private bool FilterItem(BaseItem item)
-        {
-            if (item.IsFolder)
-            {
-                return false;
-            }
-
-            if (!item.HasPathProtocol)
-            {
-                return false;
-            }
-
-            if (item is IItemByName)
-            {
-                return false;
-            }
-
-            return item.SourceType == SourceType.Library;
-        }
-
-        private async void LibraryUpdateTimerCallback(object? state)
-        {
-            List<BaseItem> items;
-
-            lock (_libraryChangedSyncLock)
-            {
-                items = _itemsAdded.ToList();
-                _itemsAdded.Clear();
-                _libraryUpdateTimer!.Dispose(); // Shouldn't be null as it just set off this callback
-                _libraryUpdateTimer = null;
-            }
-
-            if (items.Count > 10)
-            {
-                items = items.GetRange(0, 10);
-            }
-
-            foreach (var item in items)
-            {
-                var notification = new NotificationRequest
-                {
-                    NotificationType = NotificationType.NewLibraryContent.ToString(),
-                    Name = string.Format(
-                        CultureInfo.InvariantCulture,
-                        _localization.GetLocalizedString("ValueHasBeenAddedToLibrary"),
-                        GetItemName(item)),
-                    Description = item.Overview
-                };
-
-                await SendNotification(notification, item).ConfigureAwait(false);
-            }
-        }
-
-        /// <summary>
-        /// Creates a human readable name for the item.
-        /// </summary>
-        /// <param name="item">The item.</param>
-        /// <returns>A human readable name for the item.</returns>
-        public static string GetItemName(BaseItem item)
-        {
-            var name = item.Name;
-            if (item is Episode episode)
-            {
-                if (episode.IndexNumber.HasValue)
-                {
-                    name = string.Format(
-                        CultureInfo.InvariantCulture,
-                        "Ep{0} - {1}",
-                        episode.IndexNumber.Value,
-                        name);
-                }
-
-                if (episode.ParentIndexNumber.HasValue)
-                {
-                    name = string.Format(
-                        CultureInfo.InvariantCulture,
-                        "S{0}, {1}",
-                        episode.ParentIndexNumber.Value,
-                        name);
-                }
-            }
-
-            if (item is IHasSeries hasSeries)
-            {
-                name = hasSeries.SeriesName + " - " + name;
-            }
-
-            if (item is IHasAlbumArtist hasAlbumArtist)
-            {
-                var artists = hasAlbumArtist.AlbumArtists;
-
-                if (artists.Count > 0)
-                {
-                    name = artists[0] + " - " + name;
-                }
-            }
-            else if (item is IHasArtist hasArtist)
-            {
-                var artists = hasArtist.Artists;
-
-                if (artists.Count > 0)
-                {
-                    name = artists[0] + " - " + name;
-                }
-            }
-
-            return name;
-        }
-
-        private async Task SendNotification(NotificationRequest notification, BaseItem? relatedItem)
-        {
-            try
-            {
-                await _notificationManager.SendNotification(notification, relatedItem, CancellationToken.None).ConfigureAwait(false);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error sending notification");
-            }
-        }
-
-        /// <inheritdoc />
-        public void Dispose()
-        {
-            Dispose(true);
-            GC.SuppressFinalize(this);
-        }
-
-        /// <summary>
-        /// Releases unmanaged and optionally managed resources.
-        /// </summary>
-        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
-        protected virtual void Dispose(bool disposing)
-        {
-            if (_disposed)
-            {
-                return;
-            }
-
-            if (disposing)
-            {
-                _libraryUpdateTimer?.Dispose();
-            }
-
-            _libraryUpdateTimer = null;
-
-            _libraryManager.ItemAdded -= OnLibraryManagerItemAdded;
-            _appHost.HasPendingRestartChanged -= OnAppHostHasPendingRestartChanged;
-            _activityManager.EntryCreated -= OnActivityManagerEntryCreated;
-
-            _disposed = true;
-        }
-    }
-}

+ 0 - 224
Emby.Notifications/NotificationManager.cs

@@ -1,224 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Jellyfin.Data.Entities;
-using Jellyfin.Data.Enums;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Notifications;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Notifications;
-using Microsoft.Extensions.Logging;
-
-namespace Emby.Notifications
-{
-    /// <summary>
-    /// NotificationManager class.
-    /// </summary>
-    public class NotificationManager : INotificationManager
-    {
-        private readonly ILogger<NotificationManager> _logger;
-        private readonly IUserManager _userManager;
-        private readonly IServerConfigurationManager _config;
-
-        private INotificationService[] _services = Array.Empty<INotificationService>();
-        private INotificationTypeFactory[] _typeFactories = Array.Empty<INotificationTypeFactory>();
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="NotificationManager" /> class.
-        /// </summary>
-        /// <param name="logger">The logger.</param>
-        /// <param name="userManager">The user manager.</param>
-        /// <param name="config">The server configuration manager.</param>
-        public NotificationManager(
-            ILogger<NotificationManager> logger,
-            IUserManager userManager,
-            IServerConfigurationManager config)
-        {
-            _logger = logger;
-            _userManager = userManager;
-            _config = config;
-        }
-
-        private NotificationOptions GetConfiguration()
-        {
-            return _config.GetConfiguration<NotificationOptions>("notifications");
-        }
-
-        /// <inheritdoc />
-        public Task SendNotification(NotificationRequest request, CancellationToken cancellationToken)
-        {
-            return SendNotification(request, null, cancellationToken);
-        }
-
-        /// <inheritdoc />
-        public Task SendNotification(NotificationRequest request, BaseItem? relatedItem, CancellationToken cancellationToken)
-        {
-            var notificationType = request.NotificationType;
-
-            var options = string.IsNullOrEmpty(notificationType) ?
-                null :
-                GetConfiguration().GetOptions(notificationType);
-
-            var users = GetUserIds(request, options)
-                .Select(i => _userManager.GetUserById(i))
-                .Where(i => relatedItem is null || relatedItem.IsVisibleStandalone(i))
-                .ToArray();
-
-            var title = request.Name;
-            var description = request.Description;
-
-            var tasks = _services.Where(i => IsEnabled(i, notificationType))
-                .Select(i => SendNotification(request, i, users, title, description, cancellationToken));
-
-            return Task.WhenAll(tasks);
-        }
-
-        private Task SendNotification(
-            NotificationRequest request,
-            INotificationService service,
-            IEnumerable<User> users,
-            string title,
-            string description,
-            CancellationToken cancellationToken)
-        {
-            users = users.Where(i => IsEnabledForUser(service, i));
-
-            var tasks = users.Select(i => SendNotification(request, service, title, description, i, cancellationToken));
-
-            return Task.WhenAll(tasks);
-        }
-
-        private IEnumerable<Guid> GetUserIds(NotificationRequest request, NotificationOption? options)
-        {
-            if (request.SendToUserMode.HasValue)
-            {
-                switch (request.SendToUserMode.Value)
-                {
-                    case SendToUserType.Admins:
-                        return _userManager.Users.Where(i => i.HasPermission(PermissionKind.IsAdministrator))
-                                .Select(i => i.Id);
-                    case SendToUserType.All:
-                        return _userManager.UsersIds;
-                    case SendToUserType.Custom:
-                        return request.UserIds;
-                    default:
-                        throw new ArgumentException("Unrecognized SendToUserMode: " + request.SendToUserMode.Value);
-                }
-            }
-
-            if (options is not null && !string.IsNullOrEmpty(request.NotificationType))
-            {
-                var config = GetConfiguration();
-
-                return _userManager.Users
-                    .Where(i => config.IsEnabledToSendToUser(request.NotificationType, i.Id.ToString("N", CultureInfo.InvariantCulture), i))
-                    .Select(i => i.Id);
-            }
-
-            return request.UserIds;
-        }
-
-        private async Task SendNotification(
-            NotificationRequest request,
-            INotificationService service,
-            string title,
-            string description,
-            User user,
-            CancellationToken cancellationToken)
-        {
-            var notification = new UserNotification
-            {
-                Date = request.Date,
-                Description = description,
-                Level = request.Level,
-                Name = title,
-                Url = request.Url,
-                User = user
-            };
-
-            _logger.LogDebug("Sending notification via {0} to user {1}", service.Name, user.Username);
-
-            try
-            {
-                await service.SendNotification(notification, cancellationToken).ConfigureAwait(false);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error sending notification to {0}", service.Name);
-            }
-        }
-
-        private bool IsEnabledForUser(INotificationService service, User user)
-        {
-            try
-            {
-                return service.IsEnabledForUser(user);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error in IsEnabledForUser");
-                return false;
-            }
-        }
-
-        private bool IsEnabled(INotificationService service, string notificationType)
-        {
-            if (string.IsNullOrEmpty(notificationType))
-            {
-                return true;
-            }
-
-            return GetConfiguration().IsServiceEnabled(service.Name, notificationType);
-        }
-
-        /// <inheritdoc />
-        public void AddParts(IEnumerable<INotificationService> services, IEnumerable<INotificationTypeFactory> notificationTypeFactories)
-        {
-            _services = services.ToArray();
-            _typeFactories = notificationTypeFactories.ToArray();
-        }
-
-        /// <inheritdoc />
-        public List<NotificationTypeInfo> GetNotificationTypes()
-        {
-            var list = _typeFactories.Select(i =>
-            {
-                try
-                {
-                    return i.GetNotificationTypes().ToList();
-                }
-                catch (Exception ex)
-                {
-                    _logger.LogError(ex, "Error in GetNotificationTypes");
-                    return new List<NotificationTypeInfo>();
-                }
-            }).SelectMany(i => i).ToList();
-
-            var config = GetConfiguration();
-
-            foreach (var i in list)
-            {
-                i.Enabled = config.IsEnabled(i.Type);
-            }
-
-            return list;
-        }
-
-        /// <inheritdoc />
-        public IEnumerable<NameIdPair> GetNotificationServices()
-        {
-            return _services.Select(i => new NameIdPair
-            {
-                Name = i.Name,
-                Id = i.Name.GetMD5().ToString("N", CultureInfo.InvariantCulture)
-            }).OrderBy(i => i.Name);
-        }
-    }
-}

+ 0 - 21
Emby.Notifications/Properties/AssemblyInfo.cs

@@ -1,21 +0,0 @@
-using System.Reflection;
-using System.Resources;
-using System.Runtime.InteropServices;
-
-// General Information about an assembly is controlled through the following
-// set of attributes. Change these attribute values to modify the information
-// associated with an assembly.
-[assembly: AssemblyTitle("Emby.Notifications")]
-[assembly: AssemblyDescription("")]
-[assembly: AssemblyConfiguration("")]
-[assembly: AssemblyCompany("Jellyfin Project")]
-[assembly: AssemblyProduct("Jellyfin Server")]
-[assembly: AssemblyCopyright("Copyright ©  2019 Jellyfin Contributors. Code released under the GNU General Public License")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-[assembly: NeutralResourcesLanguage("en")]
-
-// Setting ComVisible to false makes the types in this assembly not visible
-// to COM components.  If you need to access a type in this assembly from
-// COM, set the ComVisible attribute to true on that type.
-[assembly: ComVisible(false)]

+ 5 - 5
Emby.Photos/Emby.Photos.csproj

@@ -15,7 +15,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="TagLibSharp" Version="2.3.0" />
+    <PackageReference Include="TagLibSharp" />
   </ItemGroup>
 
   <PropertyGroup>
@@ -26,13 +26,13 @@
 
   <!-- Code Analyzers-->
   <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
+    <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
     </PackageReference>
-    <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" />
-    <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
-    <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
+    <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
+    <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
+    <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
   </ItemGroup>
 
 </Project>

+ 17 - 35
Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Concurrent;
 using System.Collections.Generic;
@@ -33,15 +31,10 @@ namespace Emby.Server.Implementations.AppBase
         private ConfigurationStore[] _configurationStores = Array.Empty<ConfigurationStore>();
         private IConfigurationFactory[] _configurationFactories = Array.Empty<IConfigurationFactory>();
 
-        /// <summary>
-        /// The _configuration loaded.
-        /// </summary>
-        private bool _configurationLoaded;
-
         /// <summary>
         /// The _configuration.
         /// </summary>
-        private BaseApplicationConfiguration _configuration;
+        private BaseApplicationConfiguration? _configuration;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="BaseConfigurationManager" /> class.
@@ -63,17 +56,17 @@ namespace Emby.Server.Implementations.AppBase
         /// <summary>
         /// Occurs when [configuration updated].
         /// </summary>
-        public event EventHandler<EventArgs> ConfigurationUpdated;
+        public event EventHandler<EventArgs>? ConfigurationUpdated;
 
         /// <summary>
         /// Occurs when [configuration updating].
         /// </summary>
-        public event EventHandler<ConfigurationUpdateEventArgs> NamedConfigurationUpdating;
+        public event EventHandler<ConfigurationUpdateEventArgs>? NamedConfigurationUpdating;
 
         /// <summary>
         /// Occurs when [named configuration updated].
         /// </summary>
-        public event EventHandler<ConfigurationUpdateEventArgs> NamedConfigurationUpdated;
+        public event EventHandler<ConfigurationUpdateEventArgs>? NamedConfigurationUpdated;
 
         /// <summary>
         /// Gets the type of the configuration.
@@ -107,31 +100,25 @@ namespace Emby.Server.Implementations.AppBase
         {
             get
             {
-                if (_configurationLoaded)
+                if (_configuration is not null)
                 {
                     return _configuration;
                 }
 
                 lock (_configurationSyncLock)
                 {
-                    if (_configurationLoaded)
+                    if (_configuration is not null)
                     {
                         return _configuration;
                     }
 
-                    _configuration = (BaseApplicationConfiguration)ConfigurationHelper.GetXmlConfiguration(ConfigurationType, CommonApplicationPaths.SystemConfigurationFilePath, XmlSerializer);
-
-                    _configurationLoaded = true;
-
-                    return _configuration;
+                    return _configuration = (BaseApplicationConfiguration)ConfigurationHelper.GetXmlConfiguration(ConfigurationType, CommonApplicationPaths.SystemConfigurationFilePath, XmlSerializer);
                 }
             }
 
             protected set
             {
                 _configuration = value;
-
-                _configurationLoaded = value is not null;
             }
         }
 
@@ -183,7 +170,7 @@ namespace Emby.Server.Implementations.AppBase
             Logger.LogInformation("Saving system configuration");
             var path = CommonApplicationPaths.SystemConfigurationFilePath;
 
-            Directory.CreateDirectory(Path.GetDirectoryName(path));
+            Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Path can't be a root directory."));
 
             lock (_configurationSyncLock)
             {
@@ -323,25 +310,20 @@ namespace Emby.Server.Implementations.AppBase
 
         private object LoadConfiguration(string path, Type configurationType)
         {
-            if (!File.Exists(path))
-            {
-                return Activator.CreateInstance(configurationType);
-            }
-
             try
             {
-                return XmlSerializer.DeserializeFromFile(configurationType, path);
-            }
-            catch (IOException)
-            {
-                return Activator.CreateInstance(configurationType);
+                if (File.Exists(path))
+                {
+                    return XmlSerializer.DeserializeFromFile(configurationType, path);
+                }
             }
-            catch (Exception ex)
+            catch (Exception ex) when (ex is not IOException)
             {
                 Logger.LogError(ex, "Error loading configuration file: {Path}", path);
-
-                return Activator.CreateInstance(configurationType);
             }
+
+            return Activator.CreateInstance(configurationType)
+                ?? throw new InvalidOperationException("Configuration type can't be Nullable<T>.");
         }
 
         /// <inheritdoc />
@@ -367,7 +349,7 @@ namespace Emby.Server.Implementations.AppBase
             _configurations.AddOrUpdate(key, configuration, (_, _) => configuration);
 
             var path = GetConfigurationFile(key);
-            Directory.CreateDirectory(Path.GetDirectoryName(path));
+            Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Path can't be a root directory."));
 
             lock (_configurationSyncLock)
             {

+ 13 - 87
Emby.Server.Implementations/ApplicationHost.cs

@@ -11,7 +11,6 @@ using System.IO;
 using System.Linq;
 using System.Net;
 using System.Reflection;
-using System.Runtime.InteropServices;
 using System.Security.Cryptography.X509Certificates;
 using System.Threading;
 using System.Threading.Tasks;
@@ -19,7 +18,6 @@ using Emby.Dlna;
 using Emby.Dlna.Main;
 using Emby.Dlna.Ssdp;
 using Emby.Naming.Common;
-using Emby.Notifications;
 using Emby.Photos;
 using Emby.Server.Implementations.Channels;
 using Emby.Server.Implementations.Collections;
@@ -70,7 +68,6 @@ using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Notifications;
 using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Controller.Plugins;
@@ -115,15 +112,11 @@ namespace Emby.Server.Implementations
     /// </summary>
     public abstract class ApplicationHost : IServerApplicationHost, IAsyncDisposable, IDisposable
     {
-        /// <summary>
-        /// The environment variable prefixes to log at server startup.
-        /// </summary>
-        private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" };
-
         /// <summary>
         /// The disposable parts.
         /// </summary>
         private readonly ConcurrentDictionary<IDisposable, byte> _disposableParts = new();
+        private readonly DeviceId _deviceId;
 
         private readonly IFileSystem _fileSystemManager;
         private readonly IConfiguration _startupConfig;
@@ -132,7 +125,6 @@ namespace Emby.Server.Implementations
         private readonly IPluginManager _pluginManager;
 
         private List<Type> _creatingInstances;
-        private IMediaEncoder _mediaEncoder;
         private ISessionManager _sessionManager;
 
         /// <summary>
@@ -141,8 +133,6 @@ namespace Emby.Server.Implementations
         /// <value>All concrete types.</value>
         private Type[] _allConcreteTypes;
 
-        private DeviceId _deviceId;
-
         private bool _disposed = false;
 
         /// <summary>
@@ -166,6 +156,7 @@ namespace Emby.Server.Implementations
 
             Logger = LoggerFactory.CreateLogger<ApplicationHost>();
             _fileSystemManager.AddShortcutHandler(new MbLinkShortcutHandler(_fileSystemManager));
+            _deviceId = new DeviceId(ApplicationPaths, LoggerFactory);
 
             ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version;
             ApplicationVersionString = ApplicationVersion.ToString(3);
@@ -193,23 +184,9 @@ namespace Emby.Server.Implementations
 
         public bool CoreStartupHasCompleted { get; private set; }
 
-        public virtual bool CanLaunchWebBrowser
-        {
-            get
-            {
-                if (!Environment.UserInteractive)
-                {
-                    return false;
-                }
-
-                if (_startupOptions.IsService)
-                {
-                    return false;
-                }
-
-                return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS();
-            }
-        }
+        public virtual bool CanLaunchWebBrowser => Environment.UserInteractive
+            && !_startupOptions.IsService
+            && (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS());
 
         /// <summary>
         /// Gets the <see cref="INetworkManager"/> singleton instance.
@@ -286,15 +263,7 @@ namespace Emby.Server.Implementations
         /// <value>The application name.</value>
         public string ApplicationProductName { get; } = FileVersionInfo.GetVersionInfo(Assembly.GetEntryAssembly().Location).ProductName;
 
-        public string SystemId
-        {
-            get
-            {
-                _deviceId ??= new DeviceId(ApplicationPaths, LoggerFactory);
-
-                return _deviceId.Value;
-            }
-        }
+        public string SystemId => _deviceId.Value;
 
         /// <inheritdoc/>
         public string Name => ApplicationProductName;
@@ -447,7 +416,7 @@ namespace Emby.Server.Implementations
             ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated;
             ConfigurationManager.NamedConfigurationUpdated += OnConfigurationUpdated;
 
-            _mediaEncoder.SetFFmpegPath();
+            Resolve<IMediaEncoder>().SetFFmpegPath();
 
             Logger.LogInformation("ServerId: {ServerId}", SystemId);
 
@@ -615,8 +584,6 @@ namespace Emby.Server.Implementations
 
             serviceCollection.AddSingleton<IUserViewManager, UserViewManager>();
 
-            serviceCollection.AddSingleton<INotificationManager, NotificationManager>();
-
             serviceCollection.AddSingleton<IDeviceDiscovery, DeviceDiscovery>();
 
             serviceCollection.AddSingleton<IChapterManager, ChapterManager>();
@@ -659,7 +626,6 @@ namespace Emby.Server.Implementations
             var localizationManager = (LocalizationManager)Resolve<ILocalizationManager>();
             await localizationManager.LoadAll().ConfigureAwait(false);
 
-            _mediaEncoder = Resolve<IMediaEncoder>();
             _sessionManager = Resolve<ISessionManager>();
 
             SetStaticProperties();
@@ -670,36 +636,6 @@ namespace Emby.Server.Implementations
             FindParts();
         }
 
-        public static void LogEnvironmentInfo(ILogger logger, IApplicationPaths appPaths)
-        {
-            // Distinct these to prevent users from reporting problems that aren't actually problems
-            var commandLineArgs = Environment
-                .GetCommandLineArgs()
-                .Distinct();
-
-            // Get all relevant environment variables
-            var allEnvVars = Environment.GetEnvironmentVariables();
-            var relevantEnvVars = new Dictionary<object, object>();
-            foreach (var key in allEnvVars.Keys)
-            {
-                if (_relevantEnvVarPrefixes.Any(prefix => key.ToString().StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
-                {
-                    relevantEnvVars.Add(key, allEnvVars[key]);
-                }
-            }
-
-            logger.LogInformation("Environment Variables: {EnvVars}", relevantEnvVars);
-            logger.LogInformation("Arguments: {Args}", commandLineArgs);
-            logger.LogInformation("Operating system: {OS}", MediaBrowser.Common.System.OperatingSystem.Name);
-            logger.LogInformation("Architecture: {Architecture}", RuntimeInformation.OSArchitecture);
-            logger.LogInformation("64-Bit Process: {Is64Bit}", Environment.Is64BitProcess);
-            logger.LogInformation("User Interactive: {IsUserInteractive}", Environment.UserInteractive);
-            logger.LogInformation("Processor count: {ProcessorCount}", Environment.ProcessorCount);
-            logger.LogInformation("Program data path: {ProgramDataPath}", appPaths.ProgramDataPath);
-            logger.LogInformation("Web resources path: {WebPath}", appPaths.WebPath);
-            logger.LogInformation("Application directory: {ApplicationPath}", appPaths.ProgramSystemPath);
-        }
-
         private X509Certificate2 GetCertificate(string path, string password)
         {
             if (string.IsNullOrWhiteSpace(path))
@@ -786,13 +722,7 @@ namespace Emby.Server.Implementations
 
             Resolve<ILiveTvManager>().AddParts(GetExports<ILiveTvService>(), GetExports<ITunerHost>(), GetExports<IListingsProvider>());
 
-            Resolve<ISubtitleManager>().AddParts(GetExports<ISubtitleProvider>());
-
-            Resolve<IChannelManager>().AddParts(GetExports<IChannel>());
-
             Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
-
-            Resolve<INotificationManager>().AddParts(GetExports<INotificationService>(), GetExports<INotificationTypeFactory>());
         }
 
         /// <summary>
@@ -991,9 +921,6 @@ namespace Emby.Server.Implementations
             // Local metadata
             yield return typeof(BoxSetXmlSaver).Assembly;
 
-            // Notifications
-            yield return typeof(NotificationManager).Assembly;
-
             // Xbmc
             yield return typeof(ArtistNfoProvider).Assembly;
 
@@ -1032,14 +959,11 @@ namespace Emby.Server.Implementations
                 ItemsByNamePath = ApplicationPaths.InternalMetadataPath,
                 InternalMetadataPath = ApplicationPaths.InternalMetadataPath,
                 CachePath = ApplicationPaths.CachePath,
-                OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(),
-                OperatingSystemDisplayName = MediaBrowser.Common.System.OperatingSystem.Name,
                 CanLaunchWebBrowser = CanLaunchWebBrowser,
                 TranscodingTempPath = ConfigurationManager.GetTranscodePath(),
                 ServerName = FriendlyName,
                 LocalAddress = GetSmartApiUrl(request),
                 SupportsLibraryMonitor = true,
-                SystemArchitecture = RuntimeInformation.OSArchitecture,
                 PackageName = _startupOptions.PackageName
             };
         }
@@ -1051,7 +975,6 @@ namespace Emby.Server.Implementations
                 Version = ApplicationVersionString,
                 ProductName = ApplicationProductName,
                 Id = SystemId,
-                OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(),
                 ServerName = FriendlyName,
                 LocalAddress = GetSmartApiUrl(request),
                 StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted
@@ -1263,10 +1186,13 @@ namespace Emby.Server.Implementations
                 }
             }
 
-            // used for closing websockets
-            foreach (var session in _sessionManager.Sessions)
+            if (_sessionManager != null)
             {
-                await session.DisposeAsync().ConfigureAwait(false);
+                // used for closing websockets
+                foreach (var session in _sessionManager.Sessions)
+                {
+                    await session.DisposeAsync().ConfigureAwait(false);
+                }
             }
         }
     }

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

@@ -66,6 +66,7 @@ namespace Emby.Server.Implementations.Channels
         /// <param name="userDataManager">The user data manager.</param>
         /// <param name="providerManager">The provider manager.</param>
         /// <param name="memoryCache">The memory cache.</param>
+        /// <param name="channels">The channels.</param>
         public ChannelManager(
             IUserManager userManager,
             IDtoService dtoService,
@@ -75,7 +76,8 @@ namespace Emby.Server.Implementations.Channels
             IFileSystem fileSystem,
             IUserDataManager userDataManager,
             IProviderManager providerManager,
-            IMemoryCache memoryCache)
+            IMemoryCache memoryCache,
+            IEnumerable<IChannel> channels)
         {
             _userManager = userManager;
             _dtoService = dtoService;
@@ -86,18 +88,13 @@ namespace Emby.Server.Implementations.Channels
             _userDataManager = userDataManager;
             _providerManager = providerManager;
             _memoryCache = memoryCache;
+            Channels = channels.ToArray();
         }
 
-        internal IChannel[] Channels { get; private set; }
+        internal IChannel[] Channels { get; }
 
         private static TimeSpan CacheLength => TimeSpan.FromHours(3);
 
-        /// <inheritdoc />
-        public void AddParts(IEnumerable<IChannel> channels)
-        {
-            Channels = channels.ToArray();
-        }
-
         /// <inheritdoc />
         public bool EnableMediaSourceDisplay(BaseItem item)
         {

+ 1 - 3
Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Globalization;
 using System.IO;
@@ -36,7 +34,7 @@ namespace Emby.Server.Implementations.Configuration
         /// <summary>
         /// Configuration updating event.
         /// </summary>
-        public event EventHandler<GenericEventArgs<ServerConfiguration>> ConfigurationUpdating;
+        public event EventHandler<GenericEventArgs<ServerConfiguration>>? ConfigurationUpdating;
 
         /// <summary>
         /// Gets the type of the configuration.

+ 21 - 0
Emby.Server.Implementations/Data/SqliteItemRepository.cs

@@ -4477,6 +4477,24 @@ namespace Emby.Server.Implementations.Data
                 }
             }
 
+            if (query.IncludeInheritedTags.Length > 0)
+            {
+                var paramName = "@IncludeInheritedTags";
+                if (statement is null)
+                {
+                    int index = 0;
+                    string includedTags = string.Join(',', query.IncludeInheritedTags.Select(_ => paramName + index++));
+                    whereClauses.Add("((select CleanValue from ItemValues where ItemId=Guid and Type=6 and cleanvalue in (" + includedTags + ")) is not null)");
+                }
+                else
+                {
+                    for (int index = 0; index < query.IncludeInheritedTags.Length; index++)
+                    {
+                        statement.TryBind(paramName + index, GetCleanValue(query.IncludeInheritedTags[index]));
+                    }
+                }
+            }
+
             if (query.SeriesStatuses.Length > 0)
             {
                 var statuses = new List<string>();
@@ -5440,6 +5458,9 @@ AND Type = @InternalPersonType)");
 
             list.AddRange(inheritedTags.Select(i => (6, i)));
 
+            // Remove all invalid values.
+            list.RemoveAll(i => string.IsNullOrEmpty(i.Item2));
+
             return list;
         }
 

+ 15 - 16
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -7,7 +7,6 @@
 
   <ItemGroup>
     <ProjectReference Include="..\Emby.Naming\Emby.Naming.csproj" />
-    <ProjectReference Include="..\Emby.Notifications\Emby.Notifications.csproj" />
     <ProjectReference Include="..\Jellyfin.Api\Jellyfin.Api.csproj" />
     <ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" />
     <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
@@ -23,17 +22,17 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="DiscUtils.Udf" Version="0.16.13" />
-    <PackageReference Include="Jellyfin.XmlTv" Version="10.8.0" />
-    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
-    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
-    <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
-    <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" />
-    <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.2" />
-    <PackageReference Include="Mono.Nat" Version="3.0.4" />
-    <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.0" />
-    <PackageReference Include="SQLitePCL.pretty.netstandard" Version="3.1.0" />
-    <PackageReference Include="DotNet.Glob" Version="3.1.3" />
+    <PackageReference Include="DiscUtils.Udf" />
+    <PackageReference Include="Jellyfin.XmlTv" />
+    <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
+    <PackageReference Include="Microsoft.Extensions.Caching.Memory" />
+    <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
+    <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
+    <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
+    <PackageReference Include="Mono.Nat" />
+    <PackageReference Include="prometheus-net.DotNetRuntime" />
+    <PackageReference Include="SQLitePCL.pretty.netstandard" />
+    <PackageReference Include="DotNet.Glob" />
   </ItemGroup>
 
   <ItemGroup>
@@ -54,13 +53,13 @@
 
   <!-- Code Analyzers-->
   <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4">
+    <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
     </PackageReference>
-    <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
-    <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" />
-    <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
+    <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
+    <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
+    <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
   </ItemGroup>
 
   <ItemGroup>

+ 0 - 2
Emby.Server.Implementations/Images/BaseFolderImageProvider.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System.Collections.Generic;

+ 0 - 2
Emby.Server.Implementations/Images/FolderImageProvider.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using MediaBrowser.Common.Configuration;

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

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System.Collections.Generic;

+ 12 - 11
Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs

@@ -137,32 +137,33 @@ namespace Emby.Server.Implementations.LiveTv.Listings
 
         private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info)
         {
-            string episodeTitle = program.Episode?.Title;
+            string episodeTitle = program.Episode.Title;
+            var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList();
 
             var programInfo = new ProgramInfo
             {
                 ChannelId = program.ChannelId,
                 EndDate = program.EndDate.UtcDateTime,
-                EpisodeNumber = program.Episode?.Episode,
+                EpisodeNumber = program.Episode.Episode,
                 EpisodeTitle = episodeTitle,
-                Genres = program.Categories,
+                Genres = programCategories,
                 StartDate = program.StartDate.UtcDateTime,
                 Name = program.Title,
                 Overview = program.Description,
                 ProductionYear = program.CopyrightDate?.Year,
-                SeasonNumber = program.Episode?.Series,
-                IsSeries = program.Episode is not null,
+                SeasonNumber = program.Episode.Series,
+                IsSeries = program.Episode.Series is not null,
                 IsRepeat = program.IsPreviouslyShown && !program.IsNew,
                 IsPremiere = program.Premiere is not null,
-                IsKids = program.Categories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
-                IsMovie = program.Categories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
-                IsNews = program.Categories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
-                IsSports = program.Categories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
+                IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
+                IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
+                IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
+                IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
                 ImageUrl = string.IsNullOrEmpty(program.Icon?.Source) ? null : program.Icon.Source,
                 HasImage = !string.IsNullOrEmpty(program.Icon?.Source),
                 OfficialRating = string.IsNullOrEmpty(program.Rating?.Value) ? null : program.Rating.Value,
                 CommunityRating = program.StarRating,
-                SeriesId = program.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture)
+                SeriesId = program.Episode.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture)
             };
 
             if (string.IsNullOrWhiteSpace(program.ProgramId))
@@ -243,7 +244,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             {
                 Id = c.Id,
                 Name = c.DisplayName,
-                ImageUrl = string.IsNullOrEmpty(c.Icon.Source) ? null : c.Icon.Source,
+                ImageUrl = string.IsNullOrEmpty(c.Icon?.Source) ? null : c.Icon.Source,
                 Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number
             }).ToList();
         }

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

@@ -14,6 +14,7 @@ using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Extensions;
 using Jellyfin.Extensions.Json;
+using Jellyfin.Extensions.Json.Converters;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller;
@@ -58,7 +59,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             _socketFactory = socketFactory;
             _streamHelper = streamHelper;
 
-            _jsonOptions = JsonDefaults.Options;
+            _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options);
+            _jsonOptions.Converters.Add(new JsonBoolNumberConverter());
         }
 
         public string Name => "HD Homerun";

+ 125 - 2
Emby.Server.Implementations/Localization/Core/be.json

@@ -1,4 +1,127 @@
 {
-    "Sync": "Сінхранізацыя",
-    "Playlists": "Плэйліст"
+    "Sync": "Сінхранізаваць",
+    "Playlists": "Плэйлісты",
+    "Latest": "Апошні",
+    "LabelIpAddressValue": "IP-адрас: {0}",
+    "ItemAddedWithName": "{0} быў дададзены ў бібліятэку",
+    "MessageApplicationUpdated": "Сервер Jellyfin абноўлены",
+    "NotificationOptionApplicationUpdateInstalled": "Абнаўленне прыкладання ўсталявана",
+    "PluginInstalledWithName": "{0} быў усталяваны",
+    "UserCreatedWithName": "Карыстальнік {0} быў створаны",
+    "Albums": "Альбомы",
+    "Application": "Прыкладанне",
+    "AuthenticationSucceededWithUserName": "{0} паспяхова аўтэнтыфікаваны",
+    "Channels": "Каналы",
+    "ChapterNameValue": "Раздзел {0}",
+    "Collections": "Калекцыі",
+    "Default": "Па змаўчанні",
+    "FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}",
+    "Folders": "Папкі",
+    "Favorites": "Абранае",
+    "External": "Знешні",
+    "Genres": "Жанры",
+    "HeaderContinueWatching": "Працягнуць прагляд",
+    "HeaderFavoriteAlbums": "Абраныя альбомы",
+    "HeaderFavoriteEpisodes": "Абраныя серыі",
+    "HeaderFavoriteShows": "Абраныя шоу",
+    "HeaderFavoriteSongs": "Абраныя песні",
+    "HeaderLiveTV": "Прамы эфір",
+    "HeaderAlbumArtists": "Выканаўцы альбома",
+    "LabelRunningTimeValue": "Працягласць: {0}",
+    "HomeVideos": "Хатнія відэа",
+    "ItemRemovedWithName": "{0} быў выдалены з бібліятэкі",
+    "MessageApplicationUpdatedTo": "Сервер Jellyfin абноўлены да {0}",
+    "Movies": "Фільмы",
+    "Music": "Музыка",
+    "MusicVideos": "Музычныя кліпы",
+    "NameInstallFailed": "Устаноўка {0} не атрымалася",
+    "NameSeasonNumber": "Сезон {0}",
+    "NotificationOptionApplicationUpdateAvailable": "Даступна абнаўленне прыкладання",
+    "NotificationOptionPluginInstalled": "Плагін усталяваны",
+    "NotificationOptionPluginUpdateInstalled": "Абнаўленне плагіна усталявана",
+    "NotificationOptionServerRestartRequired": "Патрабуецца перазапуск сервера",
+    "Photos": "Фатаграфіі",
+    "Plugin": "Плагін",
+    "PluginUninstalledWithName": "{0} быў выдалены",
+    "PluginUpdatedWithName": "{0} быў абноўлены",
+    "ProviderValue": "Пастаўшчык: {0}",
+    "Songs": "Песні",
+    "System": "Сістэма",
+    "User": "Карыстальнік",
+    "UserDeletedWithName": "Карыстальнік {0} быў выдалены",
+    "UserDownloadingItemWithValues": "{0} спампоўваецца {1}",
+    "TaskOptimizeDatabase": "Аптымізаваць базу дадзеных",
+    "Artists": "Выканаўцы",
+    "UserOfflineFromDevice": "{0} адключыўся ад {1}",
+    "UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}",
+    "TaskCleanActivityLogDescription": "Выдаляе старэйшыя за зададзены ўзрост запісы ў журнале актыўнасці.",
+    "TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.",
+    "TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.",
+    "TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія настроены на аўтаматычнае абнаўленне.",
+    "TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.",
+    "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субтытры на аснове канфігурацыі метададзеных.",
+    "TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых змяненняў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць прадукцыйнасць.",
+    "TaskKeyframeExtractor": "Экстрактар ключавых кадраў",
+    "TasksApplicationCategory": "Прыкладанне",
+    "AppDeviceValues": "Прыкладанне: {0}, Прылада: {1}",
+    "Books": "Кнігі",
+    "CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}",
+    "DeviceOfflineWithName": "{0} адключыўся",
+    "DeviceOnlineWithName": "{0} падлучаны",
+    "Forced": "Прымусова",
+    "HeaderRecordingGroups": "Групы запісаў",
+    "HeaderNextUp": "Наступнае",
+    "HeaderFavoriteArtists": "Абраныя выканаўцы",
+    "HearingImpaired": "Са слабым слыхам",
+    "Inherit": "Атрымаць у спадчыну",
+    "MessageNamedServerConfigurationUpdatedWithValue": "Канфігурацыя сервера {0} абноўлена",
+    "MessageServerConfigurationUpdated": "Канфігурацыя сервера абноўлена",
+    "MixedContent": "Змешаны змест",
+    "NameSeasonUnknown": "Невядомы сезон",
+    "NotificationOptionInstallationFailed": "Збой усталёўкі",
+    "NewVersionIsAvailable": "Новая версія сервера Jellyfin даступная для cпампоўкі.",
+    "NotificationOptionCameraImageUploaded": "Выява камеры запампавана",
+    "NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыё спынена",
+    "NotificationOptionAudioPlayback": "Прайграванне аўдыё пачалося",
+    "NotificationOptionNewLibraryContent": "Дададзены новы кантэнт",
+    "NotificationOptionPluginError": "Збой плагіна",
+    "NotificationOptionPluginUninstalled": "Плагін выдалены",
+    "NotificationOptionTaskFailed": "Збой запланаванага задання",
+    "NotificationOptionUserLockedOut": "Карыстальнік заблакіраваны",
+    "NotificationOptionVideoPlayback": "Пачалося прайграванне відэа",
+    "NotificationOptionVideoPlaybackStopped": "Прайграванне відэа спынена",
+    "ScheduledTaskFailedWithName": "{0} не атрымалася",
+    "ScheduledTaskStartedWithName": "{0} пачалося",
+    "ServerNameNeedsToBeRestarted": "{0} трэба перазапусціць",
+    "Shows": "Шоу",
+    "StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.",
+    "SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}",
+    "TvShows": "ТБ-шоу",
+    "Undefined": "Нявызначана",
+    "UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны",
+    "UserOnlineFromDevice": "{0} падключаны з {1}",
+    "UserPasswordChangedWithName": "Пароль быў зменены для карыстальніка {0}",
+    "UserStartedPlayingItemWithValues": "{0} грае {1} на {2}",
+    "UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}",
+    "ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку",
+    "ValueSpecialEpisodeName": "Спецэпізод - {0}",
+    "VersionNumber": "Версія {0}",
+    "TasksMaintenanceCategory": "Абслугоўванне",
+    "TasksLibraryCategory": "Медыятэка",
+    "TasksChannelsCategory": "Інтэрнэт-каналы",
+    "TaskCleanActivityLog": "Ачысціць журнал актыўнасці",
+    "TaskCleanCache": "Ачысціць кэш",
+    "TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.",
+    "TaskRefreshChapterImages": "Выняць выявы раздзелаў",
+    "TaskRefreshLibrary": "Сканіраваць медыятэку",
+    "TaskRefreshLibraryDescription": "Сканіруе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.",
+    "TaskCleanLogs": "Ачысціць часопіс",
+    "TaskRefreshPeople": "Абнавіць людзей",
+    "TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.",
+    "TaskUpdatePlugins": "Абнавіць плагіны",
+    "TaskCleanTranscode": "Ачысціць каталог перакадзіравання",
+    "TaskCleanTranscodeDescription": "Выдаляе перакадзіраваныя файлы, старэйшыя за адзін дзень.",
+    "TaskRefreshChannels": "Абнавіць каналы",
+    "TaskDownloadMissingSubtitles": "Спампаваць адсутныя субтытры",
+    "TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных спісаў прайгравання HLS. Гэта задача можа працаваць у працягу доўгага часу."
 }

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

@@ -122,5 +122,6 @@
     "TaskOptimizeDatabase": "Optimizar base de datos",
     "External": "Externo",
     "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reproducción HLS más precisas. Esta tarea puede durar mucho tiempo.",
-    "TaskKeyframeExtractor": "Extractor de Fotogramas Clave"
+    "TaskKeyframeExtractor": "Extractor de Fotogramas Clave",
+    "HearingImpaired": "Discapacidad auditiva"
 }

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

@@ -123,5 +123,6 @@
     "TaskOptimizeDatabaseDescription": "فشرده سازی پایگاه داده و باز کردن فضای آزاد.اجرای این گزینه بعد از اسکن کردن کتابخانه یا تغییرات دیگر که روی پایگاه داده تأثیر میگذارند میتواند کارایی را بهبود ببخشد.",
     "TaskKeyframeExtractorDescription": "فریم های کلیدی را از فایل های ویدئویی استخراج می کند تا لیست های پخش HLS دقیق تری ایجاد کند. این کار ممکن است برای مدت طولانی اجرا شود.",
     "TaskKeyframeExtractor": "استخراج کننده فریم کلیدی",
-    "External": "خارجی"
+    "External": "خارجی",
+    "HearingImpaired": "مشکل شنوایی"
 }

+ 8 - 4
Emby.Server.Implementations/Localization/Core/gsw.json

@@ -1,7 +1,7 @@
 {
     "Albums": "Alben",
     "AppDeviceValues": "App: {0}, Gerät: {1}",
-    "Application": "Anwendung",
+    "Application": "Applikation",
     "Artists": "Künstler",
     "AuthenticationSucceededWithUserName": "{0} hat sich angemeldet",
     "Books": "Bücher",
@@ -14,7 +14,7 @@
     "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}",
     "Favorites": "Favoriten",
     "Folders": "Ordner",
-    "Genres": "Genres",
+    "Genres": "Genre",
     "HeaderAlbumArtists": "Album-Künstler",
     "HeaderContinueWatching": "weiter schauen",
     "HeaderFavoriteAlbums": "Lieblingsalben",
@@ -49,7 +49,7 @@
     "NotificationOptionAudioPlayback": "Audiowedergab gstartet",
     "NotificationOptionAudioPlaybackStopped": "Audiwedergab gstoppt",
     "NotificationOptionCameraImageUploaded": "Foti ueglade",
-    "NotificationOptionInstallationFailed": "Installationsfehler",
+    "NotificationOptionInstallationFailed": "Installationsfähler",
     "NotificationOptionNewLibraryContent": "Nöie Inhaut hinzuegfüegt",
     "NotificationOptionPluginError": "Plugin-Fäuer",
     "NotificationOptionPluginInstalled": "Plugin installiert",
@@ -120,5 +120,9 @@
     "Forced": "Erzwungen",
     "Default": "Standard",
     "TaskOptimizeDatabase": "Datenbank optimieren",
-    "External": "Extern"
+    "External": "Extern",
+    "TaskOptimizeDatabaseDescription": "Kompromiert d Datenbank und trennt freie Speicherplatz. Durch die Ufagb cha d Leistig nach em ne Scan vor Bibliothek oder andere Ufgabe verbesseret werde.",
+    "HearingImpaired": "Hörgschädigti",
+    "TaskKeyframeExtractor": "Keyframe-Extraktor",
+    "TaskKeyframeExtractorDescription": "Extrahiert Keyframes us Videodateien zum erstelle vo genauere HLS Playliste. Die Ufgab cha für e langi Zyt laufe."
 }

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

@@ -19,5 +19,10 @@
     "FailedLoginAttemptWithUserName": "Ye failed to get in, try from {0}",
     "Favorites": "Finest Loot",
     "ItemRemovedWithName": "{0} was taken from yer treasure",
-    "LabelIpAddressValue": "Ship's coordinates: {0}"
+    "LabelIpAddressValue": "Ship's coordinates: {0}",
+    "Genres": "types o' booty",
+    "TaskDownloadMissingSubtitlesDescription": "Scours the seven seas o' the internet for subtitles that be missin' based on the captain's map o' metadata.",
+    "HeaderAlbumArtists": "Buccaneers o' the musical arts",
+    "HeaderFavoriteAlbums": "Beloved booty o' musical adventures",
+    "HeaderFavoriteArtists": "Treasured scallywags o' the creative seas"
 }

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

@@ -66,7 +66,7 @@
     "PluginInstalledWithName": "{0} installerades",
     "PluginUninstalledWithName": "{0} avinstallerades",
     "PluginUpdatedWithName": "{0} uppdaterades",
-    "ProviderValue": "Källa: {0}",
+    "ProviderValue": "Leverantör: {0}",
     "ScheduledTaskFailedWithName": "{0} misslyckades",
     "ScheduledTaskStartedWithName": "{0} startades",
     "ServerNameNeedsToBeRestarted": "{0} behöver startas om",

+ 38 - 15
Emby.Server.Implementations/Plugins/PluginManager.cs

@@ -123,41 +123,64 @@ namespace Emby.Server.Implementations.Plugins
                     continue;
                 }
 
+                var assemblyLoadContext = new PluginLoadContext(plugin.Path);
+                _assemblyLoadContexts.Add(assemblyLoadContext);
+
+                var assemblies = new List<Assembly>(plugin.DllFiles.Count);
+                var loadedAll = true;
+
                 foreach (var file in plugin.DllFiles)
                 {
-                    Assembly assembly;
                     try
                     {
-                        var assemblyLoadContext = new PluginLoadContext(file);
-                        _assemblyLoadContexts.Add(assemblyLoadContext);
-
-                        assembly = assemblyLoadContext.LoadFromAssemblyPath(file);
-
-                        // Load all required types to verify that the plugin will load
-                        assembly.GetTypes();
+                        assemblies.Add(assemblyLoadContext.LoadFromAssemblyPath(file));
                     }
                     catch (FileLoadException ex)
                     {
-                        _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin.", file);
+                        _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin", file);
                         ChangePluginState(plugin, PluginStatus.Malfunctioned);
-                        continue;
+                        loadedAll = false;
+                        break;
+                    }
+#pragma warning disable CA1031 // Do not catch general exception types
+                    catch (Exception ex)
+#pragma warning restore CA1031 // Do not catch general exception types
+                    {
+                        _logger.LogError(ex, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling plugin", file);
+                        ChangePluginState(plugin, PluginStatus.Malfunctioned);
+                        loadedAll = false;
+                        break;
+                    }
+                }
+
+                if (!loadedAll)
+                {
+                    continue;
+                }
+
+                foreach (var assembly in assemblies)
+                {
+                    try
+                    {
+                        // Load all required types to verify that the plugin will load
+                        assembly.GetTypes();
                     }
                     catch (SystemException ex) when (ex is TypeLoadException or ReflectionTypeLoadException) // Undocumented exception
                     {
-                        _logger.LogError(ex, "Failed to load assembly {Path}. This error occurs when a plugin references an incompatible version of one of the shared libraries. Disabling plugin.", file);
+                        _logger.LogError(ex, "Failed to load assembly {Path}. This error occurs when a plugin references an incompatible version of one of the shared libraries. Disabling plugin", assembly.Location);
                         ChangePluginState(plugin, PluginStatus.NotSupported);
-                        continue;
+                        break;
                     }
 #pragma warning disable CA1031 // Do not catch general exception types
                     catch (Exception ex)
 #pragma warning restore CA1031 // Do not catch general exception types
                     {
-                        _logger.LogError(ex, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling plugin.", file);
+                        _logger.LogError(ex, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling plugin", assembly.Location);
                         ChangePluginState(plugin, PluginStatus.Malfunctioned);
-                        continue;
+                        break;
                     }
 
-                    _logger.LogInformation("Loaded assembly {Assembly} from {Path}", assembly.FullName, file);
+                    _logger.LogInformation("Loaded assembly {Assembly} from {Path}", assembly.FullName, assembly.Location);
                     yield return assembly;
                 }
             }

+ 19 - 20
Jellyfin.Api/Attributes/AcceptsFileAttribute.cs

@@ -2,29 +2,28 @@
 
 using System;
 
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Internal produces image attribute.
+/// </summary>
+[AttributeUsage(AttributeTargets.Method)]
+public class AcceptsFileAttribute : Attribute
 {
+    private readonly string[] _contentTypes;
+
     /// <summary>
-    /// Internal produces image attribute.
+    /// Initializes a new instance of the <see cref="AcceptsFileAttribute"/> class.
     /// </summary>
-    [AttributeUsage(AttributeTargets.Method)]
-    public class AcceptsFileAttribute : Attribute
+    /// <param name="contentTypes">Content types this endpoint produces.</param>
+    public AcceptsFileAttribute(params string[] contentTypes)
     {
-        private readonly string[] _contentTypes;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="AcceptsFileAttribute"/> class.
-        /// </summary>
-        /// <param name="contentTypes">Content types this endpoint produces.</param>
-        public AcceptsFileAttribute(params string[] contentTypes)
-        {
-            _contentTypes = contentTypes;
-        }
-
-        /// <summary>
-        /// Gets the configured content types.
-        /// </summary>
-        /// <returns>the configured content types.</returns>
-        public string[] ContentTypes => _contentTypes;
+        _contentTypes = contentTypes;
     }
+
+    /// <summary>
+    /// Gets the configured content types.
+    /// </summary>
+    /// <returns>the configured content types.</returns>
+    public string[] ContentTypes => _contentTypes;
 }

+ 11 - 12
Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs

@@ -1,18 +1,17 @@
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Produces file attribute of "image/*".
+/// </summary>
+public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute
 {
+    private const string ContentType = "image/*";
+
     /// <summary>
-    /// Produces file attribute of "image/*".
+    /// Initializes a new instance of the <see cref="AcceptsImageFileAttribute"/> class.
     /// </summary>
-    public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute
+    public AcceptsImageFileAttribute()
+        : base(ContentType)
     {
-        private const string ContentType = "image/*";
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="AcceptsImageFileAttribute"/> class.
-        /// </summary>
-        public AcceptsImageFileAttribute()
-            : base(ContentType)
-        {
-        }
     }
 }

+ 19 - 20
Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs

@@ -2,29 +2,28 @@ using System;
 using System.Collections.Generic;
 using Microsoft.AspNetCore.Mvc.Routing;
 
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Identifies an action that supports the HTTP GET method.
+/// </summary>
+public sealed class HttpSubscribeAttribute : HttpMethodAttribute
 {
+    private static readonly IEnumerable<string> _supportedMethods = new[] { "SUBSCRIBE" };
+
     /// <summary>
-    /// Identifies an action that supports the HTTP GET method.
+    /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class.
     /// </summary>
-    public sealed class HttpSubscribeAttribute : HttpMethodAttribute
+    public HttpSubscribeAttribute()
+        : base(_supportedMethods)
     {
-        private static readonly IEnumerable<string> _supportedMethods = new[] { "SUBSCRIBE" };
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class.
-        /// </summary>
-        public HttpSubscribeAttribute()
-            : base(_supportedMethods)
-        {
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class.
-        /// </summary>
-        /// <param name="template">The route template. May not be null.</param>
-        public HttpSubscribeAttribute(string template)
-            : base(_supportedMethods, template)
-            => ArgumentNullException.ThrowIfNull(template);
     }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class.
+    /// </summary>
+    /// <param name="template">The route template. May not be null.</param>
+    public HttpSubscribeAttribute(string template)
+        : base(_supportedMethods, template)
+        => ArgumentNullException.ThrowIfNull(template);
 }

+ 19 - 20
Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs

@@ -2,29 +2,28 @@ using System;
 using System.Collections.Generic;
 using Microsoft.AspNetCore.Mvc.Routing;
 
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Identifies an action that supports the HTTP GET method.
+/// </summary>
+public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute
 {
+    private static readonly IEnumerable<string> _supportedMethods = new[] { "UNSUBSCRIBE" };
+
     /// <summary>
-    /// Identifies an action that supports the HTTP GET method.
+    /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class.
     /// </summary>
-    public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute
+    public HttpUnsubscribeAttribute()
+        : base(_supportedMethods)
     {
-        private static readonly IEnumerable<string> _supportedMethods = new[] { "UNSUBSCRIBE" };
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class.
-        /// </summary>
-        public HttpUnsubscribeAttribute()
-            : base(_supportedMethods)
-        {
-        }
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class.
-        /// </summary>
-        /// <param name="template">The route template. May not be null.</param>
-        public HttpUnsubscribeAttribute(string template)
-            : base(_supportedMethods, template)
-            => ArgumentNullException.ThrowIfNull(template);
     }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class.
+    /// </summary>
+    /// <param name="template">The route template. May not be null.</param>
+    public HttpUnsubscribeAttribute(string template)
+        : base(_supportedMethods, template)
+        => ArgumentNullException.ThrowIfNull(template);
 }

+ 7 - 8
Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs

@@ -1,12 +1,11 @@
 using System;
 
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Attribute to mark a parameter as obsolete.
+/// </summary>
+[AttributeUsage(AttributeTargets.Parameter)]
+public sealed class ParameterObsoleteAttribute : Attribute
 {
-    /// <summary>
-    /// Attribute to mark a parameter as obsolete.
-    /// </summary>
-    [AttributeUsage(AttributeTargets.Parameter)]
-    public sealed class ParameterObsoleteAttribute : Attribute
-    {
-    }
 }

+ 11 - 12
Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs

@@ -1,18 +1,17 @@
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Produces file attribute of "image/*".
+/// </summary>
+public sealed class ProducesAudioFileAttribute : ProducesFileAttribute
 {
+    private const string ContentType = "audio/*";
+
     /// <summary>
-    /// Produces file attribute of "image/*".
+    /// Initializes a new instance of the <see cref="ProducesAudioFileAttribute"/> class.
     /// </summary>
-    public sealed class ProducesAudioFileAttribute : ProducesFileAttribute
+    public ProducesAudioFileAttribute()
+        : base(ContentType)
     {
-        private const string ContentType = "audio/*";
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProducesAudioFileAttribute"/> class.
-        /// </summary>
-        public ProducesAudioFileAttribute()
-            : base(ContentType)
-        {
-        }
     }
 }

+ 19 - 20
Jellyfin.Api/Attributes/ProducesFileAttribute.cs

@@ -2,29 +2,28 @@
 
 using System;
 
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Internal produces image attribute.
+/// </summary>
+[AttributeUsage(AttributeTargets.Method)]
+public class ProducesFileAttribute : Attribute
 {
+    private readonly string[] _contentTypes;
+
     /// <summary>
-    /// Internal produces image attribute.
+    /// Initializes a new instance of the <see cref="ProducesFileAttribute"/> class.
     /// </summary>
-    [AttributeUsage(AttributeTargets.Method)]
-    public class ProducesFileAttribute : Attribute
+    /// <param name="contentTypes">Content types this endpoint produces.</param>
+    public ProducesFileAttribute(params string[] contentTypes)
     {
-        private readonly string[] _contentTypes;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProducesFileAttribute"/> class.
-        /// </summary>
-        /// <param name="contentTypes">Content types this endpoint produces.</param>
-        public ProducesFileAttribute(params string[] contentTypes)
-        {
-            _contentTypes = contentTypes;
-        }
-
-        /// <summary>
-        /// Gets the configured content types.
-        /// </summary>
-        /// <returns>the configured content types.</returns>
-        public string[] ContentTypes => _contentTypes;
+        _contentTypes = contentTypes;
     }
+
+    /// <summary>
+    /// Gets the configured content types.
+    /// </summary>
+    /// <returns>the configured content types.</returns>
+    public string[] ContentTypes => _contentTypes;
 }

+ 11 - 12
Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs

@@ -1,18 +1,17 @@
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Produces file attribute of "image/*".
+/// </summary>
+public sealed class ProducesImageFileAttribute : ProducesFileAttribute
 {
+    private const string ContentType = "image/*";
+
     /// <summary>
-    /// Produces file attribute of "image/*".
+    /// Initializes a new instance of the <see cref="ProducesImageFileAttribute"/> class.
     /// </summary>
-    public sealed class ProducesImageFileAttribute : ProducesFileAttribute
+    public ProducesImageFileAttribute()
+        : base(ContentType)
     {
-        private const string ContentType = "image/*";
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProducesImageFileAttribute"/> class.
-        /// </summary>
-        public ProducesImageFileAttribute()
-            : base(ContentType)
-        {
-        }
     }
 }

+ 11 - 12
Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs

@@ -1,18 +1,17 @@
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Produces file attribute of "image/*".
+/// </summary>
+public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute
 {
+    private const string ContentType = "application/x-mpegURL";
+
     /// <summary>
-    /// Produces file attribute of "image/*".
+    /// Initializes a new instance of the <see cref="ProducesPlaylistFileAttribute"/> class.
     /// </summary>
-    public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute
+    public ProducesPlaylistFileAttribute()
+        : base(ContentType)
     {
-        private const string ContentType = "application/x-mpegURL";
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProducesPlaylistFileAttribute"/> class.
-        /// </summary>
-        public ProducesPlaylistFileAttribute()
-            : base(ContentType)
-        {
-        }
     }
 }

+ 11 - 12
Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs

@@ -1,18 +1,17 @@
-namespace Jellyfin.Api.Attributes
+namespace Jellyfin.Api.Attributes;
+
+/// <summary>
+/// Produces file attribute of "video/*".
+/// </summary>
+public sealed class ProducesVideoFileAttribute : ProducesFileAttribute
 {
+    private const string ContentType = "video/*";
+
     /// <summary>
-    /// Produces file attribute of "video/*".
+    /// Initializes a new instance of the <see cref="ProducesVideoFileAttribute"/> class.
     /// </summary>
-    public sealed class ProducesVideoFileAttribute : ProducesFileAttribute
+    public ProducesVideoFileAttribute()
+        : base(ContentType)
     {
-        private const string ContentType = "video/*";
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ProducesVideoFileAttribute"/> class.
-        /// </summary>
-        public ProducesVideoFileAttribute()
-            : base(ContentType)
-        {
-        }
     }
 }

+ 2 - 1
Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs

@@ -1,4 +1,5 @@
 using System.Threading.Tasks;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -29,7 +30,7 @@ namespace Jellyfin.Api.Auth.AnonymousLanAccessPolicy
         /// <inheritdoc />
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AnonymousLanAccessRequirement requirement)
         {
-            var ip = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
+            var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIp();
 
             // Loopback will be on LAN, so we can accept null.
             if (ip is null || _networkManager.IsInLocalNetwork(ip))

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

@@ -1,113 +0,0 @@
-using System.Security.Claims;
-using Jellyfin.Api.Extensions;
-using Jellyfin.Api.Helpers;
-using Jellyfin.Data.Enums;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Library;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-
-namespace Jellyfin.Api.Auth
-{
-    /// <summary>
-    /// Base authorization handler.
-    /// </summary>
-    /// <typeparam name="T">Type of Authorization Requirement.</typeparam>
-    public abstract class BaseAuthorizationHandler<T> : AuthorizationHandler<T>
-        where T : IAuthorizationRequirement
-    {
-        private readonly IUserManager _userManager;
-        private readonly INetworkManager _networkManager;
-        private readonly IHttpContextAccessor _httpContextAccessor;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="BaseAuthorizationHandler{T}"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
-        protected BaseAuthorizationHandler(
-            IUserManager userManager,
-            INetworkManager networkManager,
-            IHttpContextAccessor httpContextAccessor)
-        {
-            _userManager = userManager;
-            _networkManager = networkManager;
-            _httpContextAccessor = httpContextAccessor;
-        }
-
-        /// <summary>
-        /// Validate authenticated claims.
-        /// </summary>
-        /// <param name="claimsPrincipal">Request claims.</param>
-        /// <param name="ignoreSchedule">Whether to ignore parental control.</param>
-        /// <param name="localAccessOnly">Whether access is to be allowed locally only.</param>
-        /// <param name="requiredDownloadPermission">Whether validation requires download permission.</param>
-        /// <returns>Validated claim status.</returns>
-        protected bool ValidateClaims(
-            ClaimsPrincipal claimsPrincipal,
-            bool ignoreSchedule = false,
-            bool localAccessOnly = false,
-            bool requiredDownloadPermission = false)
-        {
-            // ApiKey is currently global admin, always allow.
-            var isApiKey = claimsPrincipal.GetIsApiKey();
-            if (isApiKey)
-            {
-                return true;
-            }
-
-            // Ensure claim has userId.
-            var userId = claimsPrincipal.GetUserId();
-            if (userId.Equals(default))
-            {
-                return false;
-            }
-
-            // Ensure userId links to a valid user.
-            var user = _userManager.GetUserById(userId);
-            if (user is null)
-            {
-                return false;
-            }
-
-            // Ensure user is not disabled.
-            if (user.HasPermission(PermissionKind.IsDisabled))
-            {
-                return false;
-            }
-
-            var isInLocalNetwork = _httpContextAccessor.HttpContext is not null
-                && _networkManager.IsInLocalNetwork(_httpContextAccessor.HttpContext.GetNormalizedRemoteIp());
-
-            // User cannot access remotely and user is remote
-            if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !isInLocalNetwork)
-            {
-                return false;
-            }
-
-            if (localAccessOnly && !isInLocalNetwork)
-            {
-                return false;
-            }
-
-            // User attempting to access out of parental control hours.
-            if (!ignoreSchedule
-                && !user.HasPermission(PermissionKind.IsAdministrator)
-                && !user.IsParentalScheduleAllowed())
-            {
-                return false;
-            }
-
-            // User attempting to download without permission.
-            if (requiredDownloadPermission
-                && !user.HasPermission(PermissionKind.EnableContentDownloading))
-            {
-                return false;
-            }
-
-            return true;
-        }
-    }
-}

+ 48 - 5
Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs

@@ -1,4 +1,8 @@
 using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Library;
 using Microsoft.AspNetCore.Authorization;
@@ -9,8 +13,12 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
     /// <summary>
     /// Default authorization handler.
     /// </summary>
-    public class DefaultAuthorizationHandler : BaseAuthorizationHandler<DefaultAuthorizationRequirement>
+    public class DefaultAuthorizationHandler : AuthorizationHandler<DefaultAuthorizationRequirement>
     {
+        private readonly IUserManager _userManager;
+        private readonly INetworkManager _networkManager;
+        private readonly IHttpContextAccessor _httpContextAccessor;
+
         /// <summary>
         /// Initializes a new instance of the <see cref="DefaultAuthorizationHandler"/> class.
         /// </summary>
@@ -21,21 +29,56 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
             IUserManager userManager,
             INetworkManager networkManager,
             IHttpContextAccessor httpContextAccessor)
-            : base(userManager, networkManager, httpContextAccessor)
         {
+            _userManager = userManager;
+            _networkManager = networkManager;
+            _httpContextAccessor = httpContextAccessor;
         }
 
         /// <inheritdoc />
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement)
         {
-            var validated = ValidateClaims(context.User);
-            if (validated)
+            var isApiKey = context.User.GetIsApiKey();
+            var userId = context.User.GetUserId();
+            // This likely only happens during the wizard, so skip the default checks and let any other handlers do it
+            if (!isApiKey && userId.Equals(default))
+            {
+                return Task.CompletedTask;
+            }
+
+            var isInLocalNetwork = _httpContextAccessor.HttpContext is not null
+                                   && _networkManager.IsInLocalNetwork(_httpContextAccessor.HttpContext.GetNormalizedRemoteIp());
+            var user = _userManager.GetUserById(userId);
+            if (user is null)
+            {
+                throw new ResourceNotFoundException();
+            }
+
+            // User cannot access remotely and user is remote
+            if (!isInLocalNetwork && !user.HasPermission(PermissionKind.EnableRemoteAccess))
+            {
+                context.Fail();
+                return Task.CompletedTask;
+            }
+
+            // Admins can do everything
+            if (isApiKey || context.User.IsInRole(UserRoles.Administrator))
             {
                 context.Succeed(requirement);
+                return Task.CompletedTask;
             }
-            else
+
+            // It's not great to have this check, but parental schedule must usually be honored except in a few rare cases
+            if (requirement.ValidateParentalSchedule && !user.IsParentalScheduleAllowed())
             {
                 context.Fail();
+                return Task.CompletedTask;
+            }
+
+            // Only succeed if the requirement isn't a subclass as any subclassed requirement will handle success in its own handler
+            if (requirement.GetType() == typeof(DefaultAuthorizationRequirement))
+            {
+                context.Succeed(requirement);
             }
 
             return Task.CompletedTask;

+ 13 - 0
Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs

@@ -7,5 +7,18 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
     /// </summary>
     public class DefaultAuthorizationRequirement : IAuthorizationRequirement
     {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DefaultAuthorizationRequirement"/> class.
+        /// </summary>
+        /// <param name="validateParentalSchedule">A value indicating whether to validate parental schedule.</param>
+        public DefaultAuthorizationRequirement(bool validateParentalSchedule = true)
+        {
+            ValidateParentalSchedule = validateParentalSchedule;
+        }
+
+        /// <summary>
+        /// Gets a value indicating whether to ignore parental schedule.
+        /// </summary>
+        public bool ValidateParentalSchedule { get; }
     }
 }

+ 0 - 44
Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs

@@ -1,44 +0,0 @@
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Library;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-
-namespace Jellyfin.Api.Auth.DownloadPolicy
-{
-    /// <summary>
-    /// Download authorization handler.
-    /// </summary>
-    public class DownloadHandler : BaseAuthorizationHandler<DownloadRequirement>
-    {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DownloadHandler"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
-        public DownloadHandler(
-            IUserManager userManager,
-            INetworkManager networkManager,
-            IHttpContextAccessor httpContextAccessor)
-            : base(userManager, networkManager, httpContextAccessor)
-        {
-        }
-
-        /// <inheritdoc />
-        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DownloadRequirement requirement)
-        {
-            var validated = ValidateClaims(context.User);
-            if (validated)
-            {
-                context.Succeed(requirement);
-            }
-            else
-            {
-                context.Fail();
-            }
-
-            return Task.CompletedTask;
-        }
-    }
-}

+ 0 - 11
Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs

@@ -1,11 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-
-namespace Jellyfin.Api.Auth.DownloadPolicy
-{
-    /// <summary>
-    /// The download permission requirement.
-    /// </summary>
-    public class DownloadRequirement : IAuthorizationRequirement
-    {
-    }
-}

+ 0 - 56
Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs

@@ -1,56 +0,0 @@
-using System.Threading.Tasks;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Library;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-
-namespace Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy
-{
-    /// <summary>
-    /// Ignore parental control schedule and allow before startup wizard has been completed.
-    /// </summary>
-    public class FirstTimeOrIgnoreParentalControlSetupHandler : BaseAuthorizationHandler<FirstTimeOrIgnoreParentalControlSetupRequirement>
-    {
-        private readonly IConfigurationManager _configurationManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="FirstTimeOrIgnoreParentalControlSetupHandler"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
-        /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
-        public FirstTimeOrIgnoreParentalControlSetupHandler(
-            IUserManager userManager,
-            INetworkManager networkManager,
-            IHttpContextAccessor httpContextAccessor,
-            IConfigurationManager configurationManager)
-            : base(userManager, networkManager, httpContextAccessor)
-        {
-            _configurationManager = configurationManager;
-        }
-
-        /// <inheritdoc />
-        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeOrIgnoreParentalControlSetupRequirement requirement)
-        {
-            if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
-            {
-                context.Succeed(requirement);
-                return Task.CompletedTask;
-            }
-
-            var validated = ValidateClaims(context.User, ignoreSchedule: true);
-            if (validated)
-            {
-                context.Succeed(requirement);
-            }
-            else
-            {
-                context.Fail();
-            }
-
-            return Task.CompletedTask;
-        }
-    }
-}

+ 0 - 11
Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs

@@ -1,11 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-
-namespace Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy
-{
-    /// <summary>
-    /// First time setup or ignore parental controls requirement.
-    /// </summary>
-    public class FirstTimeOrIgnoreParentalControlSetupRequirement : IAuthorizationRequirement
-    {
-    }
-}

+ 0 - 56
Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs

@@ -1,56 +0,0 @@
-using System.Threading.Tasks;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Library;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-
-namespace Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy
-{
-    /// <summary>
-    /// Authorization handler for requiring first time setup or default privileges.
-    /// </summary>
-    public class FirstTimeSetupOrDefaultHandler : BaseAuthorizationHandler<FirstTimeSetupOrDefaultRequirement>
-    {
-        private readonly IConfigurationManager _configurationManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="FirstTimeSetupOrDefaultHandler" /> class.
-        /// </summary>
-        /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
-        public FirstTimeSetupOrDefaultHandler(
-            IConfigurationManager configurationManager,
-            IUserManager userManager,
-            INetworkManager networkManager,
-            IHttpContextAccessor httpContextAccessor)
-            : base(userManager, networkManager, httpContextAccessor)
-        {
-            _configurationManager = configurationManager;
-        }
-
-        /// <inheritdoc />
-        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrDefaultRequirement requirement)
-        {
-            if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
-            {
-                context.Succeed(requirement);
-                return Task.CompletedTask;
-            }
-
-            var validated = ValidateClaims(context.User);
-            if (validated)
-            {
-                context.Succeed(requirement);
-            }
-            else
-            {
-                context.Fail();
-            }
-
-            return Task.CompletedTask;
-        }
-    }
-}

+ 0 - 11
Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs

@@ -1,11 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-
-namespace Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy
-{
-    /// <summary>
-    /// The authorization requirement, requiring incomplete first time setup or default privileges, for the authorization handler.
-    /// </summary>
-    public class FirstTimeSetupOrDefaultRequirement : IAuthorizationRequirement
-    {
-    }
-}

+ 0 - 11
Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs

@@ -1,11 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-
-namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy
-{
-    /// <summary>
-    /// The authorization requirement, requiring incomplete first time setup or elevated privileges, for the authorization handler.
-    /// </summary>
-    public class FirstTimeSetupOrElevatedRequirement : IAuthorizationRequirement
-    {
-    }
-}

+ 28 - 18
Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs → Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs

@@ -1,39 +1,36 @@
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
 using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Net;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Library;
 using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
 
-namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy
+namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy
 {
     /// <summary>
-    /// Authorization handler for requiring first time setup or elevated privileges.
+    /// Authorization handler for requiring first time setup or default privileges.
     /// </summary>
-    public class FirstTimeSetupOrElevatedHandler : BaseAuthorizationHandler<FirstTimeSetupOrElevatedRequirement>
+    public class FirstTimeSetupHandler : AuthorizationHandler<FirstTimeSetupRequirement>
     {
         private readonly IConfigurationManager _configurationManager;
+        private readonly IUserManager _userManager;
 
         /// <summary>
-        /// Initializes a new instance of the <see cref="FirstTimeSetupOrElevatedHandler" /> class.
+        /// Initializes a new instance of the <see cref="FirstTimeSetupHandler" /> class.
         /// </summary>
         /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
         /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
-        public FirstTimeSetupOrElevatedHandler(
+        public FirstTimeSetupHandler(
             IConfigurationManager configurationManager,
-            IUserManager userManager,
-            INetworkManager networkManager,
-            IHttpContextAccessor httpContextAccessor)
-            : base(userManager, networkManager, httpContextAccessor)
+            IUserManager userManager)
         {
             _configurationManager = configurationManager;
+            _userManager = userManager;
         }
 
         /// <inheritdoc />
-        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrElevatedRequirement requirement)
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupRequirement requirement)
         {
             if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
             {
@@ -41,14 +38,27 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy
                 return Task.CompletedTask;
             }
 
-            var validated = ValidateClaims(context.User);
-            if (validated && context.User.IsInRole(UserRoles.Administrator))
+            if (requirement.RequireAdmin && !context.User.IsInRole(UserRoles.Administrator))
+            {
+                context.Fail();
+                return Task.CompletedTask;
+            }
+
+            if (!requirement.ValidateParentalSchedule)
             {
                 context.Succeed(requirement);
+                return Task.CompletedTask;
             }
-            else
+
+            var user = _userManager.GetUserById(context.User.GetUserId());
+            if (user is null)
             {
-                context.Fail();
+                throw new ResourceNotFoundException();
+            }
+
+            if (user.IsParentalScheduleAllowed())
+            {
+                context.Succeed(requirement);
             }
 
             return Task.CompletedTask;

+ 25 - 0
Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupRequirement.cs

@@ -0,0 +1,25 @@
+using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
+
+namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy
+{
+    /// <summary>
+    /// The authorization requirement, requiring incomplete first time setup or default privileges, for the authorization handler.
+    /// </summary>
+    public class FirstTimeSetupRequirement : DefaultAuthorizationRequirement
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="FirstTimeSetupRequirement"/> class.
+        /// </summary>
+        /// <param name="validateParentalSchedule">A value indicating whether to ignore parental schedule.</param>
+        /// <param name="requireAdmin">A value indicating whether administrator role is required.</param>
+        public FirstTimeSetupRequirement(bool validateParentalSchedule = false, bool requireAdmin = true) : base(validateParentalSchedule)
+        {
+            RequireAdmin = requireAdmin;
+        }
+
+        /// <summary>
+        /// Gets a value indicating whether administrator role is required.
+        /// </summary>
+        public bool RequireAdmin { get; }
+    }
+}

+ 0 - 44
Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs

@@ -1,44 +0,0 @@
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Library;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-
-namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy
-{
-    /// <summary>
-    /// Escape schedule controls handler.
-    /// </summary>
-    public class IgnoreParentalControlHandler : BaseAuthorizationHandler<IgnoreParentalControlRequirement>
-    {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="IgnoreParentalControlHandler"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
-        public IgnoreParentalControlHandler(
-            IUserManager userManager,
-            INetworkManager networkManager,
-            IHttpContextAccessor httpContextAccessor)
-            : base(userManager, networkManager, httpContextAccessor)
-        {
-        }
-
-        /// <inheritdoc />
-        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement)
-        {
-            var validated = ValidateClaims(context.User, ignoreSchedule: true);
-            if (validated)
-            {
-                context.Succeed(requirement);
-            }
-            else
-            {
-                context.Fail();
-            }
-
-            return Task.CompletedTask;
-        }
-    }
-}

+ 0 - 11
Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs

@@ -1,11 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-
-namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy
-{
-    /// <summary>
-    /// Escape schedule controls requirement.
-    /// </summary>
-    public class IgnoreParentalControlRequirement : IAuthorizationRequirement
-    {
-    }
-}

+ 19 - 8
Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs

@@ -1,7 +1,7 @@
-using System.Threading.Tasks;
+using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Library;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 
@@ -10,27 +10,38 @@ namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy
     /// <summary>
     /// Local access or require elevated privileges handler.
     /// </summary>
-    public class LocalAccessOrRequiresElevationHandler : BaseAuthorizationHandler<LocalAccessOrRequiresElevationRequirement>
+    public class LocalAccessOrRequiresElevationHandler : AuthorizationHandler<LocalAccessOrRequiresElevationRequirement>
     {
+        private readonly INetworkManager _networkManager;
+        private readonly IHttpContextAccessor _httpContextAccessor;
+
         /// <summary>
         /// Initializes a new instance of the <see cref="LocalAccessOrRequiresElevationHandler"/> class.
         /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
         /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
         /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
         public LocalAccessOrRequiresElevationHandler(
-            IUserManager userManager,
             INetworkManager networkManager,
             IHttpContextAccessor httpContextAccessor)
-            : base(userManager, networkManager, httpContextAccessor)
         {
+            _networkManager = networkManager;
+            _httpContextAccessor = httpContextAccessor;
         }
 
         /// <inheritdoc />
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessOrRequiresElevationRequirement requirement)
         {
-            var validated = ValidateClaims(context.User, localAccessOnly: true);
-            if (validated || context.User.IsInRole(UserRoles.Administrator))
+            var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIp();
+
+            // Loopback will be on LAN, so we can accept null.
+            if (ip is null || _networkManager.IsInLocalNetwork(ip))
+            {
+                context.Succeed(requirement);
+
+                return Task.CompletedTask;
+            }
+
+            if (context.User.IsInRole(UserRoles.Administrator))
             {
                 context.Succeed(requirement);
             }

+ 1 - 1
Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs

@@ -1,4 +1,4 @@
-using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Authorization;
 
 namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy
 {

+ 0 - 44
Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs

@@ -1,44 +0,0 @@
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Library;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-
-namespace Jellyfin.Api.Auth.LocalAccessPolicy
-{
-    /// <summary>
-    /// Local access handler.
-    /// </summary>
-    public class LocalAccessHandler : BaseAuthorizationHandler<LocalAccessRequirement>
-    {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="LocalAccessHandler"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
-        public LocalAccessHandler(
-            IUserManager userManager,
-            INetworkManager networkManager,
-            IHttpContextAccessor httpContextAccessor)
-            : base(userManager, networkManager, httpContextAccessor)
-        {
-        }
-
-        /// <inheritdoc />
-        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement)
-        {
-            var validated = ValidateClaims(context.User, localAccessOnly: true);
-            if (validated)
-            {
-                context.Succeed(requirement);
-            }
-            else
-            {
-                context.Fail();
-            }
-
-            return Task.CompletedTask;
-        }
-    }
-}

+ 0 - 11
Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs

@@ -1,11 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-
-namespace Jellyfin.Api.Auth.LocalAccessPolicy
-{
-    /// <summary>
-    /// The local access authorization requirement.
-    /// </summary>
-    public class LocalAccessRequirement : IAuthorizationRequirement
-    {
-    }
-}

+ 0 - 45
Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs

@@ -1,45 +0,0 @@
-using System.Threading.Tasks;
-using Jellyfin.Api.Constants;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Library;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-
-namespace Jellyfin.Api.Auth.RequiresElevationPolicy
-{
-    /// <summary>
-    /// Authorization handler for requiring elevated privileges.
-    /// </summary>
-    public class RequiresElevationHandler : BaseAuthorizationHandler<RequiresElevationRequirement>
-    {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="RequiresElevationHandler"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
-        public RequiresElevationHandler(
-            IUserManager userManager,
-            INetworkManager networkManager,
-            IHttpContextAccessor httpContextAccessor)
-            : base(userManager, networkManager, httpContextAccessor)
-        {
-        }
-
-        /// <inheritdoc />
-        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresElevationRequirement requirement)
-        {
-            var validated = ValidateClaims(context.User);
-            if (validated && context.User.IsInRole(UserRoles.Administrator))
-            {
-                context.Succeed(requirement);
-            }
-            else
-            {
-                context.Fail();
-            }
-
-            return Task.CompletedTask;
-        }
-    }
-}

+ 0 - 11
Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs

@@ -1,11 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-
-namespace Jellyfin.Api.Auth.RequiresElevationPolicy
-{
-    /// <summary>
-    /// The authorization requirement for requiring elevated privileges in the authorization handler.
-    /// </summary>
-    public class RequiresElevationRequirement : IAuthorizationRequirement
-    {
-    }
-}

+ 8 - 38
Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs

@@ -1,19 +1,17 @@
 using System.Threading.Tasks;
 using Jellyfin.Api.Extensions;
-using Jellyfin.Api.Helpers;
 using Jellyfin.Data.Enums;
-using MediaBrowser.Common.Net;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.SyncPlay;
 using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
 
 namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
 {
     /// <summary>
     /// Default authorization handler.
     /// </summary>
-    public class SyncPlayAccessHandler : BaseAuthorizationHandler<SyncPlayAccessRequirement>
+    public class SyncPlayAccessHandler : AuthorizationHandler<SyncPlayAccessRequirement>
     {
         private readonly ISyncPlayManager _syncPlayManager;
         private readonly IUserManager _userManager;
@@ -23,14 +21,9 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
         /// </summary>
         /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param>
         /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
-        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
         public SyncPlayAccessHandler(
             ISyncPlayManager syncPlayManager,
-            IUserManager userManager,
-            INetworkManager networkManager,
-            IHttpContextAccessor httpContextAccessor)
-            : base(userManager, networkManager, httpContextAccessor)
+            IUserManager userManager)
         {
             _syncPlayManager = syncPlayManager;
             _userManager = userManager;
@@ -39,27 +32,20 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
         /// <inheritdoc />
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement)
         {
-            if (!ValidateClaims(context.User))
-            {
-                context.Fail();
-                return Task.CompletedTask;
-            }
-
             var userId = context.User.GetUserId();
             var user = _userManager.GetUserById(userId);
+            if (user is null)
+            {
+                throw new ResourceNotFoundException();
+            }
 
             if (requirement.RequiredAccess == SyncPlayAccessRequirementType.HasAccess)
             {
-                if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups
-                    || user.SyncPlayAccess == SyncPlayUserAccessType.JoinGroups
+                if (user.SyncPlayAccess is SyncPlayUserAccessType.CreateAndJoinGroups or SyncPlayUserAccessType.JoinGroups
                     || _syncPlayManager.IsUserActive(userId))
                 {
                     context.Succeed(requirement);
                 }
-                else
-                {
-                    context.Fail();
-                }
             }
             else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.CreateGroup)
             {
@@ -67,10 +53,6 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
                 {
                     context.Succeed(requirement);
                 }
-                else
-                {
-                    context.Fail();
-                }
             }
             else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.JoinGroup)
             {
@@ -79,10 +61,6 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
                 {
                     context.Succeed(requirement);
                 }
-                else
-                {
-                    context.Fail();
-                }
             }
             else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.IsInGroup)
             {
@@ -90,14 +68,6 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
                 {
                     context.Succeed(requirement);
                 }
-                else
-                {
-                    context.Fail();
-                }
-            }
-            else
-            {
-                context.Fail();
             }
 
             return Task.CompletedTask;

+ 3 - 3
Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs

@@ -1,12 +1,12 @@
-using Jellyfin.Data.Enums;
-using Microsoft.AspNetCore.Authorization;
+using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
+using Jellyfin.Data.Enums;
 
 namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
 {
     /// <summary>
     /// The default authorization requirement.
     /// </summary>
-    public class SyncPlayAccessRequirement : IAuthorizationRequirement
+    public class SyncPlayAccessRequirement : DefaultAuthorizationRequirement
     {
         /// <summary>
         /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.

+ 42 - 0
Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs

@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+using Jellyfin.Api.Extensions;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.UserPermissionPolicy
+{
+    /// <summary>
+    /// User permission authorization handler.
+    /// </summary>
+    public class UserPermissionHandler : AuthorizationHandler<UserPermissionRequirement>
+    {
+        private readonly IUserManager _userManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="UserPermissionHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        public UserPermissionHandler(IUserManager userManager)
+        {
+            _userManager = userManager;
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement)
+        {
+            var user = _userManager.GetUserById(context.User.GetUserId());
+            if (user is null)
+            {
+                throw new ResourceNotFoundException();
+            }
+
+            if (user.HasPermission(requirement.RequiredPermission))
+            {
+                context.Succeed(requirement);
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 26 - 0
Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs

@@ -0,0 +1,26 @@
+using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
+using Jellyfin.Data.Enums;
+
+namespace Jellyfin.Api.Auth.UserPermissionPolicy
+{
+    /// <summary>
+    /// The user permission requirement.
+    /// </summary>
+    public class UserPermissionRequirement : DefaultAuthorizationRequirement
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="UserPermissionRequirement"/> class.
+        /// </summary>
+        /// <param name="requiredPermission">The required <see cref="PermissionKind"/>.</param>
+        /// <param name="validateParentalSchedule">Whether to validate the user's parental schedule.</param>
+        public UserPermissionRequirement(PermissionKind requiredPermission, bool validateParentalSchedule = true) : base(validateParentalSchedule)
+        {
+            RequiredPermission = requiredPermission;
+        }
+
+        /// <summary>
+        /// Gets the required user permission.
+        /// </summary>
+        public PermissionKind RequiredPermission { get; }
+    }
+}

+ 26 - 27
Jellyfin.Api/BaseJellyfinApiController.cs

@@ -4,35 +4,34 @@ using Jellyfin.Api.Results;
 using Jellyfin.Extensions.Json;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api
+namespace Jellyfin.Api;
+
+/// <summary>
+/// Base api controller for the API setting a default route.
+/// </summary>
+[ApiController]
+[Route("[controller]")]
+[Produces(
+    MediaTypeNames.Application.Json,
+    JsonDefaults.CamelCaseMediaType,
+    JsonDefaults.PascalCaseMediaType)]
+public class BaseJellyfinApiController : ControllerBase
 {
     /// <summary>
-    /// Base api controller for the API setting a default route.
+    /// Create a new <see cref="OkResult{T}"/>.
     /// </summary>
-    [ApiController]
-    [Route("[controller]")]
-    [Produces(
-        MediaTypeNames.Application.Json,
-        JsonDefaults.CamelCaseMediaType,
-        JsonDefaults.PascalCaseMediaType)]
-    public class BaseJellyfinApiController : ControllerBase
-    {
-        /// <summary>
-        /// Create a new <see cref="OkResult{T}"/>.
-        /// </summary>
-        /// <param name="value">The value to return.</param>
-        /// <typeparam name="T">The type to return.</typeparam>
-        /// <returns>The <see cref="ActionResult{T}"/>.</returns>
-        protected ActionResult<IEnumerable<T>> Ok<T>(IEnumerable<T>? value)
-            => new OkResult<IEnumerable<T>?>(value);
+    /// <param name="value">The value to return.</param>
+    /// <typeparam name="T">The type to return.</typeparam>
+    /// <returns>The <see cref="ActionResult{T}"/>.</returns>
+    protected ActionResult<IEnumerable<T>> Ok<T>(IEnumerable<T>? value)
+        => new OkResult<IEnumerable<T>?>(value);
 
-        /// <summary>
-        /// Create a new <see cref="OkResult{T}"/>.
-        /// </summary>
-        /// <param name="value">The value to return.</param>
-        /// <typeparam name="T">The type to return.</typeparam>
-        /// <returns>The <see cref="ActionResult{T}"/>.</returns>
-        protected ActionResult<T> Ok<T>(T value)
-            => new OkResult<T>(value);
-    }
+    /// <summary>
+    /// Create a new <see cref="OkResult{T}"/>.
+    /// </summary>
+    /// <param name="value">The value to return.</param>
+    /// <typeparam name="T">The type to return.</typeparam>
+    /// <returns>The <see cref="ActionResult{T}"/>.</returns>
+    protected ActionResult<T> Ok<T>(T value)
+        => new OkResult<T>(value);
 }

+ 8 - 9
Jellyfin.Api/Constants/AuthenticationSchemes.cs

@@ -1,13 +1,12 @@
-namespace Jellyfin.Api.Constants
+namespace Jellyfin.Api.Constants;
+
+/// <summary>
+/// Authentication schemes for user authentication in the API.
+/// </summary>
+public static class AuthenticationSchemes
 {
     /// <summary>
-    /// Authentication schemes for user authentication in the API.
+    /// Scheme name for the custom legacy authentication.
     /// </summary>
-    public static class AuthenticationSchemes
-    {
-        /// <summary>
-        /// Scheme name for the custom legacy authentication.
-        /// </summary>
-        public const string CustomAuthentication = "CustomAuthentication";
-    }
+    public const string CustomAuthentication = "CustomAuthentication";
 }

+ 32 - 33
Jellyfin.Api/Constants/InternalClaimTypes.cs

@@ -1,43 +1,42 @@
-namespace Jellyfin.Api.Constants
+namespace Jellyfin.Api.Constants;
+
+/// <summary>
+/// Internal claim types for authorization.
+/// </summary>
+public static class InternalClaimTypes
 {
     /// <summary>
-    /// Internal claim types for authorization.
+    /// User Id.
     /// </summary>
-    public static class InternalClaimTypes
-    {
-        /// <summary>
-        /// User Id.
-        /// </summary>
-        public const string UserId = "Jellyfin-UserId";
+    public const string UserId = "Jellyfin-UserId";
 
-        /// <summary>
-        /// Device Id.
-        /// </summary>
-        public const string DeviceId = "Jellyfin-DeviceId";
+    /// <summary>
+    /// Device Id.
+    /// </summary>
+    public const string DeviceId = "Jellyfin-DeviceId";
 
-        /// <summary>
-        /// Device.
-        /// </summary>
-        public const string Device = "Jellyfin-Device";
+    /// <summary>
+    /// Device.
+    /// </summary>
+    public const string Device = "Jellyfin-Device";
 
-        /// <summary>
-        /// Client.
-        /// </summary>
-        public const string Client = "Jellyfin-Client";
+    /// <summary>
+    /// Client.
+    /// </summary>
+    public const string Client = "Jellyfin-Client";
 
-        /// <summary>
-        /// Version.
-        /// </summary>
-        public const string Version = "Jellyfin-Version";
+    /// <summary>
+    /// Version.
+    /// </summary>
+    public const string Version = "Jellyfin-Version";
 
-        /// <summary>
-        /// Token.
-        /// </summary>
-        public const string Token = "Jellyfin-Token";
+    /// <summary>
+    /// Token.
+    /// </summary>
+    public const string Token = "Jellyfin-Token";
 
-        /// <summary>
-        /// Is Api Key.
-        /// </summary>
-        public const string IsApiKey = "Jellyfin-IsApiKey";
-    }
+    /// <summary>
+    /// Is Api Key.
+    /// </summary>
+    public const string IsApiKey = "Jellyfin-IsApiKey";
 }

+ 84 - 75
Jellyfin.Api/Constants/Policies.cs

@@ -1,78 +1,87 @@
-namespace Jellyfin.Api.Constants
+namespace Jellyfin.Api.Constants;
+
+/// <summary>
+/// Policies for the API authorization.
+/// </summary>
+public static class Policies
 {
     /// <summary>
-    /// Policies for the API authorization.
-    /// </summary>
-    public static class Policies
-    {
-        /// <summary>
-        /// Policy name for default authorization.
-        /// </summary>
-        public const string DefaultAuthorization = "DefaultAuthorization";
-
-        /// <summary>
-        /// Policy name for requiring first time setup or elevated privileges.
-        /// </summary>
-        public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated";
-
-        /// <summary>
-        /// Policy name for requiring elevated privileges.
-        /// </summary>
-        public const string RequiresElevation = "RequiresElevation";
-
-        /// <summary>
-        /// Policy name for allowing local access only.
-        /// </summary>
-        public const string LocalAccessOnly = "LocalAccessOnly";
-
-        /// <summary>
-        /// Policy name for escaping schedule controls.
-        /// </summary>
-        public const string IgnoreParentalControl = "IgnoreParentalControl";
-
-        /// <summary>
-        /// Policy name for requiring download permission.
-        /// </summary>
-        public const string Download = "Download";
-
-        /// <summary>
-        /// Policy name for requiring first time setup or default permissions.
-        /// </summary>
-        public const string FirstTimeSetupOrDefault = "FirstTimeSetupOrDefault";
-
-        /// <summary>
-        /// Policy name for requiring local access or elevated privileges.
-        /// </summary>
-        public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation";
-
-        /// <summary>
-        /// Policy name for requiring (anonymous) LAN access.
-        /// </summary>
-        public const string AnonymousLanAccessPolicy = "AnonymousLanAccessPolicy";
-
-        /// <summary>
-        /// Policy name for escaping schedule controls or requiring first time setup.
-        /// </summary>
-        public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl";
-
-        /// <summary>
-        /// Policy name for accessing SyncPlay.
-        /// </summary>
-        public const string SyncPlayHasAccess = "SyncPlayHasAccess";
-
-        /// <summary>
-        /// Policy name for creating a SyncPlay group.
-        /// </summary>
-        public const string SyncPlayCreateGroup = "SyncPlayCreateGroup";
-
-        /// <summary>
-        /// Policy name for joining a SyncPlay group.
-        /// </summary>
-        public const string SyncPlayJoinGroup = "SyncPlayJoinGroup";
-
-        /// <summary>
-        /// Policy name for accessing a SyncPlay group.
-        /// </summary>
-        public const string SyncPlayIsInGroup = "SyncPlayIsInGroup";
-    }
+    /// Policy name for requiring first time setup or elevated privileges.
+    /// </summary>
+    public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated";
+
+    /// <summary>
+    /// Policy name for requiring elevated privileges.
+    /// </summary>
+    public const string RequiresElevation = "RequiresElevation";
+
+    /// <summary>
+    /// Policy name for allowing local access only.
+    /// </summary>
+    public const string LocalAccessOnly = "LocalAccessOnly";
+
+    /// <summary>
+    /// Policy name for escaping schedule controls.
+    /// </summary>
+    public const string IgnoreParentalControl = "IgnoreParentalControl";
+
+    /// <summary>
+    /// Policy name for requiring download permission.
+    /// </summary>
+    public const string Download = "Download";
+
+    /// <summary>
+    /// Policy name for requiring first time setup or default permissions.
+    /// </summary>
+    public const string FirstTimeSetupOrDefault = "FirstTimeSetupOrDefault";
+
+    /// <summary>
+    /// Policy name for requiring local access or elevated privileges.
+    /// </summary>
+    public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation";
+
+    /// <summary>
+    /// Policy name for requiring (anonymous) LAN access.
+    /// </summary>
+    public const string AnonymousLanAccessPolicy = "AnonymousLanAccessPolicy";
+
+    /// <summary>
+    /// Policy name for escaping schedule controls or requiring first time setup.
+    /// </summary>
+    public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl";
+
+    /// <summary>
+    /// Policy name for accessing SyncPlay.
+    /// </summary>
+    public const string SyncPlayHasAccess = "SyncPlayHasAccess";
+
+    /// <summary>
+    /// Policy name for creating a SyncPlay group.
+    /// </summary>
+    public const string SyncPlayCreateGroup = "SyncPlayCreateGroup";
+
+    /// <summary>
+    /// Policy name for joining a SyncPlay group.
+    /// </summary>
+    public const string SyncPlayJoinGroup = "SyncPlayJoinGroup";
+
+    /// <summary>
+    /// Policy name for accessing a SyncPlay group.
+    /// </summary>
+    public const string SyncPlayIsInGroup = "SyncPlayIsInGroup";
+
+    /// <summary>
+    /// Policy name for accessing collection management.
+    /// </summary>
+    public const string CollectionManagement = "CollectionManagement";
+
+    /// <summary>
+    /// Policy name for accessing LiveTV.
+    /// </summary>
+    public const string LiveTvAccess = "LiveTvAccess";
+
+    /// <summary>
+    /// Policy name for managing LiveTV.
+    /// </summary>
+    public const string LiveTvManagement = "LiveTvManagement";
 }

+ 16 - 17
Jellyfin.Api/Constants/UserRoles.cs

@@ -1,23 +1,22 @@
-namespace Jellyfin.Api.Constants
+namespace Jellyfin.Api.Constants;
+
+/// <summary>
+/// Constants for user roles used in the authentication and authorization for the API.
+/// </summary>
+public static class UserRoles
 {
     /// <summary>
-    /// Constants for user roles used in the authentication and authorization for the API.
+    /// Guest user.
     /// </summary>
-    public static class UserRoles
-    {
-        /// <summary>
-        /// Guest user.
-        /// </summary>
-        public const string Guest = "Guest";
+    public const string Guest = "Guest";
 
-        /// <summary>
-        /// Regular user with no special privileges.
-        /// </summary>
-        public const string User = "User";
+    /// <summary>
+    /// Regular user with no special privileges.
+    /// </summary>
+    public const string User = "User";
 
-        /// <summary>
-        /// Administrator user with elevated privileges.
-        /// </summary>
-        public const string Administrator = "Administrator";
-    }
+    /// <summary>
+    /// Administrator user with elevated privileges.
+    /// </summary>
+    public const string Administrator = "Administrator";
 }

+ 38 - 39
Jellyfin.Api/Controllers/ActivityLogController.cs

@@ -8,50 +8,49 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Activity log controller.
+/// </summary>
+[Route("System/ActivityLog")]
+[Authorize(Policy = Policies.RequiresElevation)]
+public class ActivityLogController : BaseJellyfinApiController
 {
+    private readonly IActivityManager _activityManager;
+
     /// <summary>
-    /// Activity log controller.
+    /// Initializes a new instance of the <see cref="ActivityLogController"/> class.
     /// </summary>
-    [Route("System/ActivityLog")]
-    [Authorize(Policy = Policies.RequiresElevation)]
-    public class ActivityLogController : BaseJellyfinApiController
+    /// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param>
+    public ActivityLogController(IActivityManager activityManager)
     {
-        private readonly IActivityManager _activityManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ActivityLogController"/> class.
-        /// </summary>
-        /// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param>
-        public ActivityLogController(IActivityManager activityManager)
-        {
-            _activityManager = activityManager;
-        }
+        _activityManager = activityManager;
+    }
 
-        /// <summary>
-        /// Gets activity log entries.
-        /// </summary>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="minDate">Optional. The minimum date. Format = ISO.</param>
-        /// <param name="hasUserId">Optional. Filter log entries if it has user id, or not.</param>
-        /// <response code="200">Activity log returned.</response>
-        /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns>
-        [HttpGet("Entries")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<QueryResult<ActivityLogEntry>>> GetLogEntries(
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] DateTime? minDate,
-            [FromQuery] bool? hasUserId)
+    /// <summary>
+    /// Gets activity log entries.
+    /// </summary>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="minDate">Optional. The minimum date. Format = ISO.</param>
+    /// <param name="hasUserId">Optional. Filter log entries if it has user id, or not.</param>
+    /// <response code="200">Activity log returned.</response>
+    /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns>
+    [HttpGet("Entries")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<QueryResult<ActivityLogEntry>>> GetLogEntries(
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] DateTime? minDate,
+        [FromQuery] bool? hasUserId)
+    {
+        return await _activityManager.GetPagedResultAsync(new ActivityLogQuery
         {
-            return await _activityManager.GetPagedResultAsync(new ActivityLogQuery
-            {
-                Skip = startIndex,
-                Limit = limit,
-                MinDate = minDate,
-                HasUserId = hasUserId
-            }).ConfigureAwait(false);
-        }
+            Skip = startIndex,
+            Limit = limit,
+            MinDate = minDate,
+            HasUserId = hasUserId
+        }).ConfigureAwait(false);
     }
 }

+ 54 - 55
Jellyfin.Api/Controllers/ApiKeyController.cs

@@ -7,70 +7,69 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Authentication controller.
+/// </summary>
+[Route("Auth")]
+public class ApiKeyController : BaseJellyfinApiController
 {
+    private readonly IAuthenticationManager _authenticationManager;
+
     /// <summary>
-    /// Authentication controller.
+    /// Initializes a new instance of the <see cref="ApiKeyController"/> class.
     /// </summary>
-    [Route("Auth")]
-    public class ApiKeyController : BaseJellyfinApiController
+    /// <param name="authenticationManager">Instance of <see cref="IAuthenticationManager"/> interface.</param>
+    public ApiKeyController(IAuthenticationManager authenticationManager)
     {
-        private readonly IAuthenticationManager _authenticationManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ApiKeyController"/> class.
-        /// </summary>
-        /// <param name="authenticationManager">Instance of <see cref="IAuthenticationManager"/> interface.</param>
-        public ApiKeyController(IAuthenticationManager authenticationManager)
-        {
-            _authenticationManager = authenticationManager;
-        }
+        _authenticationManager = authenticationManager;
+    }
 
-        /// <summary>
-        /// Get all keys.
-        /// </summary>
-        /// <response code="200">Api keys retrieved.</response>
-        /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns>
-        [HttpGet("Keys")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys()
-        {
-            var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false);
+    /// <summary>
+    /// Get all keys.
+    /// </summary>
+    /// <response code="200">Api keys retrieved.</response>
+    /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns>
+    [HttpGet("Keys")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys()
+    {
+        var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false);
 
-            return new QueryResult<AuthenticationInfo>(keys);
-        }
+        return new QueryResult<AuthenticationInfo>(keys);
+    }
 
-        /// <summary>
-        /// Create a new api key.
-        /// </summary>
-        /// <param name="app">Name of the app using the authentication key.</param>
-        /// <response code="204">Api key created.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Keys")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> CreateKey([FromQuery, Required] string app)
-        {
-            await _authenticationManager.CreateApiKey(app).ConfigureAwait(false);
+    /// <summary>
+    /// Create a new api key.
+    /// </summary>
+    /// <param name="app">Name of the app using the authentication key.</param>
+    /// <response code="204">Api key created.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Keys")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> CreateKey([FromQuery, Required] string app)
+    {
+        await _authenticationManager.CreateApiKey(app).ConfigureAwait(false);
 
-            return NoContent();
-        }
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Remove an api key.
-        /// </summary>
-        /// <param name="key">The access token to delete.</param>
-        /// <response code="204">Api key deleted.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Keys/{key}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> RevokeKey([FromRoute, Required] string key)
-        {
-            await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false);
+    /// <summary>
+    /// Remove an api key.
+    /// </summary>
+    /// <param name="key">The access token to delete.</param>
+    /// <response code="204">Api key deleted.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Keys/{key}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> RevokeKey([FromRoute, Required] string key)
+    {
+        await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false);
 
-            return NoContent();
-        }
+        return NoContent();
     }
 }

+ 404 - 406
Jellyfin.Api/Controllers/ArtistsController.cs

@@ -1,7 +1,6 @@
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
@@ -17,464 +16,463 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The artists controller.
+/// </summary>
+[Route("Artists")]
+[Authorize]
+public class ArtistsController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IUserManager _userManager;
+    private readonly IDtoService _dtoService;
+
     /// <summary>
-    /// The artists controller.
+    /// Initializes a new instance of the <see cref="ArtistsController"/> class.
     /// </summary>
-    [Route("Artists")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class ArtistsController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    public ArtistsController(
+        ILibraryManager libraryManager,
+        IUserManager userManager,
+        IDtoService dtoService)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-        private readonly IDtoService _dtoService;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ArtistsController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        public ArtistsController(
-            ILibraryManager libraryManager,
-            IUserManager userManager,
-            IDtoService dtoService)
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+        _dtoService = dtoService;
+    }
+
+    /// <summary>
+    /// Gets all artists from a given item, folder, or the entire library.
+    /// </summary>
+    /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="searchTerm">Optional. Search term.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+    /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+    /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
+    /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+    /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+    /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
+    /// <param name="enableUserData">Optional, include user data.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+    /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
+    /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+    /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+    /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+    /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+    /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
+    /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <param name="enableTotalRecordCount">Total record count.</param>
+    /// <response code="200">Artists returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the artists.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetArtists(
+        [FromQuery] double? minCommunityRating,
+        [FromQuery] int? startIndex,
+        [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] bool? isFavorite,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] string? person,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+        [FromQuery] Guid? userId,
+        [FromQuery] string? nameStartsWithOrGreater,
+        [FromQuery] string? nameStartsWith,
+        [FromQuery] string? nameLessThan,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery] bool? enableImages = true,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+        User? user = null;
+        BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
+
+        if (userId.HasValue && !userId.Equals(default))
         {
-            _libraryManager = libraryManager;
-            _userManager = userManager;
-            _dtoService = dtoService;
+            user = _userManager.GetUserById(userId.Value);
         }
 
-        /// <summary>
-        /// Gets all artists from a given item, folder, or the entire library.
-        /// </summary>
-        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="searchTerm">Optional. Search term.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
-        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
-        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
-        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
-        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
-        /// <param name="enableUserData">Optional, include user data.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
-        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
-        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <param name="enableTotalRecordCount">Total record count.</param>
-        /// <response code="200">Artists returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the artists.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetArtists(
-            [FromQuery] double? minCommunityRating,
-            [FromQuery] int? startIndex,
-            [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] bool? isFavorite,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery] bool? enableImages = true,
-            [FromQuery] bool enableTotalRecordCount = true)
+        var query = new InternalItemsQuery(user)
         {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+            ExcludeItemTypes = excludeItemTypes,
+            IncludeItemTypes = includeItemTypes,
+            MediaTypes = mediaTypes,
+            StartIndex = startIndex,
+            Limit = limit,
+            IsFavorite = isFavorite,
+            NameLessThan = nameLessThan,
+            NameStartsWith = nameStartsWith,
+            NameStartsWithOrGreater = nameStartsWithOrGreater,
+            Tags = tags,
+            OfficialRatings = officialRatings,
+            Genres = genres,
+            GenreIds = genreIds,
+            StudioIds = studioIds,
+            Person = person,
+            PersonIds = personIds,
+            PersonTypes = personTypes,
+            Years = years,
+            MinCommunityRating = minCommunityRating,
+            DtoOptions = dtoOptions,
+            SearchTerm = searchTerm,
+            EnableTotalRecordCount = enableTotalRecordCount,
+            OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
+        };
 
-            User? user = null;
-            BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
-
-            if (userId.HasValue && !userId.Equals(default))
+        if (parentId.HasValue)
+        {
+            if (parentItem is Folder)
             {
-                user = _userManager.GetUserById(userId.Value);
+                query.AncestorIds = new[] { parentId.Value };
             }
-
-            var query = new InternalItemsQuery(user)
+            else
             {
-                ExcludeItemTypes = excludeItemTypes,
-                IncludeItemTypes = includeItemTypes,
-                MediaTypes = mediaTypes,
-                StartIndex = startIndex,
-                Limit = limit,
-                IsFavorite = isFavorite,
-                NameLessThan = nameLessThan,
-                NameStartsWith = nameStartsWith,
-                NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = tags,
-                OfficialRatings = officialRatings,
-                Genres = genres,
-                GenreIds = genreIds,
-                StudioIds = studioIds,
-                Person = person,
-                PersonIds = personIds,
-                PersonTypes = personTypes,
-                Years = years,
-                MinCommunityRating = minCommunityRating,
-                DtoOptions = dtoOptions,
-                SearchTerm = searchTerm,
-                EnableTotalRecordCount = enableTotalRecordCount,
-                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
-            };
-
-            if (parentId.HasValue)
+                query.ItemIds = new[] { parentId.Value };
+            }
+        }
+
+        // Studios
+        if (studios.Length != 0)
+        {
+            query.StudioIds = studios.Select(i =>
             {
-                if (parentItem is Folder)
+                try
                 {
-                    query.AncestorIds = new[] { parentId.Value };
+                    return _libraryManager.GetStudio(i);
                 }
-                else
+                catch
                 {
-                    query.ItemIds = new[] { parentId.Value };
+                    return null;
                 }
-            }
+            }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+        }
 
-            // Studios
-            if (studios.Length != 0)
+        foreach (var filter in filters)
+        {
+            switch (filter)
             {
-                query.StudioIds = studios.Select(i =>
-                {
-                    try
-                    {
-                        return _libraryManager.GetStudio(i);
-                    }
-                    catch
-                    {
-                        return null;
-                    }
-                }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+                case ItemFilter.Dislikes:
+                    query.IsLiked = false;
+                    break;
+                case ItemFilter.IsFavorite:
+                    query.IsFavorite = true;
+                    break;
+                case ItemFilter.IsFavoriteOrLikes:
+                    query.IsFavoriteOrLiked = true;
+                    break;
+                case ItemFilter.IsFolder:
+                    query.IsFolder = true;
+                    break;
+                case ItemFilter.IsNotFolder:
+                    query.IsFolder = false;
+                    break;
+                case ItemFilter.IsPlayed:
+                    query.IsPlayed = true;
+                    break;
+                case ItemFilter.IsResumable:
+                    query.IsResumable = true;
+                    break;
+                case ItemFilter.IsUnplayed:
+                    query.IsPlayed = false;
+                    break;
+                case ItemFilter.Likes:
+                    query.IsLiked = true;
+                    break;
             }
+        }
 
-            foreach (var filter in filters)
+        var result = _libraryManager.GetArtists(query);
+
+        var dtos = result.Items.Select(i =>
+        {
+            var (baseItem, itemCounts) = i;
+            var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+            if (includeItemTypes.Length != 0)
             {
-                switch (filter)
-                {
-                    case ItemFilter.Dislikes:
-                        query.IsLiked = false;
-                        break;
-                    case ItemFilter.IsFavorite:
-                        query.IsFavorite = true;
-                        break;
-                    case ItemFilter.IsFavoriteOrLikes:
-                        query.IsFavoriteOrLiked = true;
-                        break;
-                    case ItemFilter.IsFolder:
-                        query.IsFolder = true;
-                        break;
-                    case ItemFilter.IsNotFolder:
-                        query.IsFolder = false;
-                        break;
-                    case ItemFilter.IsPlayed:
-                        query.IsPlayed = true;
-                        break;
-                    case ItemFilter.IsResumable:
-                        query.IsResumable = true;
-                        break;
-                    case ItemFilter.IsUnplayed:
-                        query.IsPlayed = false;
-                        break;
-                    case ItemFilter.Likes:
-                        query.IsLiked = true;
-                        break;
-                }
+                dto.ChildCount = itemCounts.ItemCount;
+                dto.ProgramCount = itemCounts.ProgramCount;
+                dto.SeriesCount = itemCounts.SeriesCount;
+                dto.EpisodeCount = itemCounts.EpisodeCount;
+                dto.MovieCount = itemCounts.MovieCount;
+                dto.TrailerCount = itemCounts.TrailerCount;
+                dto.AlbumCount = itemCounts.AlbumCount;
+                dto.SongCount = itemCounts.SongCount;
+                dto.ArtistCount = itemCounts.ArtistCount;
             }
 
-            var result = _libraryManager.GetArtists(query);
+            return dto;
+        });
 
-            var dtos = result.Items.Select(i =>
-            {
-                var (baseItem, itemCounts) = i;
-                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+        return new QueryResult<BaseItemDto>(
+            query.StartIndex,
+            result.TotalRecordCount,
+            dtos.ToArray());
+    }
 
-                if (includeItemTypes.Length != 0)
-                {
-                    dto.ChildCount = itemCounts.ItemCount;
-                    dto.ProgramCount = itemCounts.ProgramCount;
-                    dto.SeriesCount = itemCounts.SeriesCount;
-                    dto.EpisodeCount = itemCounts.EpisodeCount;
-                    dto.MovieCount = itemCounts.MovieCount;
-                    dto.TrailerCount = itemCounts.TrailerCount;
-                    dto.AlbumCount = itemCounts.AlbumCount;
-                    dto.SongCount = itemCounts.SongCount;
-                    dto.ArtistCount = itemCounts.ArtistCount;
-                }
+    /// <summary>
+    /// Gets all album artists from a given item, folder, or the entire library.
+    /// </summary>
+    /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="searchTerm">Optional. Search term.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+    /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+    /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
+    /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+    /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+    /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
+    /// <param name="enableUserData">Optional, include user data.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+    /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
+    /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+    /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+    /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+    /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+    /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
+    /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <param name="enableTotalRecordCount">Total record count.</param>
+    /// <response code="200">Album artists returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the album artists.</returns>
+    [HttpGet("AlbumArtists")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists(
+        [FromQuery] double? minCommunityRating,
+        [FromQuery] int? startIndex,
+        [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] bool? isFavorite,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] string? person,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+        [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+        [FromQuery] Guid? userId,
+        [FromQuery] string? nameStartsWithOrGreater,
+        [FromQuery] string? nameStartsWith,
+        [FromQuery] string? nameLessThan,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery] bool? enableImages = true,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-                return dto;
-            });
+        User? user = null;
+        BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
 
-            return new QueryResult<BaseItemDto>(
-                query.StartIndex,
-                result.TotalRecordCount,
-                dtos.ToArray());
+        if (userId.HasValue && !userId.Equals(default))
+        {
+            user = _userManager.GetUserById(userId.Value);
         }
 
-        /// <summary>
-        /// Gets all album artists from a given item, folder, or the entire library.
-        /// </summary>
-        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="searchTerm">Optional. Search term.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
-        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
-        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
-        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
-        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
-        /// <param name="enableUserData">Optional, include user data.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
-        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
-        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <param name="enableTotalRecordCount">Total record count.</param>
-        /// <response code="200">Album artists returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the album artists.</returns>
-        [HttpGet("AlbumArtists")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists(
-            [FromQuery] double? minCommunityRating,
-            [FromQuery] int? startIndex,
-            [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] bool? isFavorite,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
-            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery] bool? enableImages = true,
-            [FromQuery] bool enableTotalRecordCount = true)
+        var query = new InternalItemsQuery(user)
         {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-
-            User? user = null;
-            BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
+            ExcludeItemTypes = excludeItemTypes,
+            IncludeItemTypes = includeItemTypes,
+            MediaTypes = mediaTypes,
+            StartIndex = startIndex,
+            Limit = limit,
+            IsFavorite = isFavorite,
+            NameLessThan = nameLessThan,
+            NameStartsWith = nameStartsWith,
+            NameStartsWithOrGreater = nameStartsWithOrGreater,
+            Tags = tags,
+            OfficialRatings = officialRatings,
+            Genres = genres,
+            GenreIds = genreIds,
+            StudioIds = studioIds,
+            Person = person,
+            PersonIds = personIds,
+            PersonTypes = personTypes,
+            Years = years,
+            MinCommunityRating = minCommunityRating,
+            DtoOptions = dtoOptions,
+            SearchTerm = searchTerm,
+            EnableTotalRecordCount = enableTotalRecordCount,
+            OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
+        };
 
-            if (userId.HasValue && !userId.Equals(default))
+        if (parentId.HasValue)
+        {
+            if (parentItem is Folder)
             {
-                user = _userManager.GetUserById(userId.Value);
+                query.AncestorIds = new[] { parentId.Value };
             }
-
-            var query = new InternalItemsQuery(user)
+            else
             {
-                ExcludeItemTypes = excludeItemTypes,
-                IncludeItemTypes = includeItemTypes,
-                MediaTypes = mediaTypes,
-                StartIndex = startIndex,
-                Limit = limit,
-                IsFavorite = isFavorite,
-                NameLessThan = nameLessThan,
-                NameStartsWith = nameStartsWith,
-                NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = tags,
-                OfficialRatings = officialRatings,
-                Genres = genres,
-                GenreIds = genreIds,
-                StudioIds = studioIds,
-                Person = person,
-                PersonIds = personIds,
-                PersonTypes = personTypes,
-                Years = years,
-                MinCommunityRating = minCommunityRating,
-                DtoOptions = dtoOptions,
-                SearchTerm = searchTerm,
-                EnableTotalRecordCount = enableTotalRecordCount,
-                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
-            };
-
-            if (parentId.HasValue)
+                query.ItemIds = new[] { parentId.Value };
+            }
+        }
+
+        // Studios
+        if (studios.Length != 0)
+        {
+            query.StudioIds = studios.Select(i =>
             {
-                if (parentItem is Folder)
+                try
                 {
-                    query.AncestorIds = new[] { parentId.Value };
+                    return _libraryManager.GetStudio(i);
                 }
-                else
+                catch
                 {
-                    query.ItemIds = new[] { parentId.Value };
+                    return null;
                 }
-            }
+            }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+        }
 
-            // Studios
-            if (studios.Length != 0)
+        foreach (var filter in filters)
+        {
+            switch (filter)
             {
-                query.StudioIds = studios.Select(i =>
-                {
-                    try
-                    {
-                        return _libraryManager.GetStudio(i);
-                    }
-                    catch
-                    {
-                        return null;
-                    }
-                }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+                case ItemFilter.Dislikes:
+                    query.IsLiked = false;
+                    break;
+                case ItemFilter.IsFavorite:
+                    query.IsFavorite = true;
+                    break;
+                case ItemFilter.IsFavoriteOrLikes:
+                    query.IsFavoriteOrLiked = true;
+                    break;
+                case ItemFilter.IsFolder:
+                    query.IsFolder = true;
+                    break;
+                case ItemFilter.IsNotFolder:
+                    query.IsFolder = false;
+                    break;
+                case ItemFilter.IsPlayed:
+                    query.IsPlayed = true;
+                    break;
+                case ItemFilter.IsResumable:
+                    query.IsResumable = true;
+                    break;
+                case ItemFilter.IsUnplayed:
+                    query.IsPlayed = false;
+                    break;
+                case ItemFilter.Likes:
+                    query.IsLiked = true;
+                    break;
             }
+        }
 
-            foreach (var filter in filters)
-            {
-                switch (filter)
-                {
-                    case ItemFilter.Dislikes:
-                        query.IsLiked = false;
-                        break;
-                    case ItemFilter.IsFavorite:
-                        query.IsFavorite = true;
-                        break;
-                    case ItemFilter.IsFavoriteOrLikes:
-                        query.IsFavoriteOrLiked = true;
-                        break;
-                    case ItemFilter.IsFolder:
-                        query.IsFolder = true;
-                        break;
-                    case ItemFilter.IsNotFolder:
-                        query.IsFolder = false;
-                        break;
-                    case ItemFilter.IsPlayed:
-                        query.IsPlayed = true;
-                        break;
-                    case ItemFilter.IsResumable:
-                        query.IsResumable = true;
-                        break;
-                    case ItemFilter.IsUnplayed:
-                        query.IsPlayed = false;
-                        break;
-                    case ItemFilter.Likes:
-                        query.IsLiked = true;
-                        break;
-                }
-            }
+        var result = _libraryManager.GetAlbumArtists(query);
 
-            var result = _libraryManager.GetAlbumArtists(query);
+        var dtos = result.Items.Select(i =>
+        {
+            var (baseItem, itemCounts) = i;
+            var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
 
-            var dtos = result.Items.Select(i =>
+            if (includeItemTypes.Length != 0)
             {
-                var (baseItem, itemCounts) = i;
-                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
-                if (includeItemTypes.Length != 0)
-                {
-                    dto.ChildCount = itemCounts.ItemCount;
-                    dto.ProgramCount = itemCounts.ProgramCount;
-                    dto.SeriesCount = itemCounts.SeriesCount;
-                    dto.EpisodeCount = itemCounts.EpisodeCount;
-                    dto.MovieCount = itemCounts.MovieCount;
-                    dto.TrailerCount = itemCounts.TrailerCount;
-                    dto.AlbumCount = itemCounts.AlbumCount;
-                    dto.SongCount = itemCounts.SongCount;
-                    dto.ArtistCount = itemCounts.ArtistCount;
-                }
-
-                return dto;
-            });
+                dto.ChildCount = itemCounts.ItemCount;
+                dto.ProgramCount = itemCounts.ProgramCount;
+                dto.SeriesCount = itemCounts.SeriesCount;
+                dto.EpisodeCount = itemCounts.EpisodeCount;
+                dto.MovieCount = itemCounts.MovieCount;
+                dto.TrailerCount = itemCounts.TrailerCount;
+                dto.AlbumCount = itemCounts.AlbumCount;
+                dto.SongCount = itemCounts.SongCount;
+                dto.ArtistCount = itemCounts.ArtistCount;
+            }
 
-            return new QueryResult<BaseItemDto>(
-                query.StartIndex,
-                result.TotalRecordCount,
-                dtos.ToArray());
-        }
+            return dto;
+        });
 
-        /// <summary>
-        /// Gets an artist by name.
-        /// </summary>
-        /// <param name="name">Studio name.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <response code="200">Artist returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the artist.</returns>
-        [HttpGet("{name}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId)
-        {
-            var dtoOptions = new DtoOptions().AddClientFields(User);
+        return new QueryResult<BaseItemDto>(
+            query.StartIndex,
+            result.TotalRecordCount,
+            dtos.ToArray());
+    }
 
-            var item = _libraryManager.GetArtist(name, dtoOptions);
+    /// <summary>
+    /// Gets an artist by name.
+    /// </summary>
+    /// <param name="name">Studio name.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <response code="200">Artist returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the artist.</returns>
+    [HttpGet("{name}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId)
+    {
+        var dtoOptions = new DtoOptions().AddClientFields(User);
 
-            if (userId.HasValue && !userId.Value.Equals(default))
-            {
-                var user = _userManager.GetUserById(userId.Value);
+        var item = _libraryManager.GetArtist(name, dtoOptions);
 
-                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
-            }
+        if (userId.HasValue && !userId.Value.Equals(default))
+        {
+            var user = _userManager.GetUserById(userId.Value);
 
-            return _dtoService.GetBaseItemDto(item, dtoOptions);
+            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
         }
+
+        return _dtoService.GetBaseItemDto(item, dtoOptions);
     }
 }

+ 339 - 340
Jellyfin.Api/Controllers/AudioController.cs

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

+ 42 - 43
Jellyfin.Api/Controllers/BrandingController.cs

@@ -4,54 +4,53 @@ using MediaBrowser.Model.Branding;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Branding controller.
+/// </summary>
+public class BrandingController : BaseJellyfinApiController
 {
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+
     /// <summary>
-    /// Branding controller.
+    /// Initializes a new instance of the <see cref="BrandingController"/> class.
     /// </summary>
-    public class BrandingController : BaseJellyfinApiController
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    public BrandingController(IServerConfigurationManager serverConfigurationManager)
     {
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="BrandingController"/> class.
-        /// </summary>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        public BrandingController(IServerConfigurationManager serverConfigurationManager)
-        {
-            _serverConfigurationManager = serverConfigurationManager;
-        }
+        _serverConfigurationManager = serverConfigurationManager;
+    }
 
-        /// <summary>
-        /// Gets branding configuration.
-        /// </summary>
-        /// <response code="200">Branding configuration returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns>
-        [HttpGet("Configuration")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<BrandingOptions> GetBrandingOptions()
-        {
-            return _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
-        }
+    /// <summary>
+    /// Gets branding configuration.
+    /// </summary>
+    /// <response code="200">Branding configuration returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns>
+    [HttpGet("Configuration")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<BrandingOptions> GetBrandingOptions()
+    {
+        return _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+    }
 
-        /// <summary>
-        /// Gets branding css.
-        /// </summary>
-        /// <response code="200">Branding css returned.</response>
-        /// <response code="204">No branding css configured.</response>
-        /// <returns>
-        /// An <see cref="OkResult"/> containing the branding css if exist,
-        /// or a <see cref="NoContentResult"/> if the css is not configured.
-        /// </returns>
-        [HttpGet("Css")]
-        [HttpGet("Css.css", Name = "GetBrandingCss_2")]
-        [Produces("text/css")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult<string> GetBrandingCss()
-        {
-            var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
-            return options.CustomCss ?? string.Empty;
-        }
+    /// <summary>
+    /// Gets branding css.
+    /// </summary>
+    /// <response code="200">Branding css returned.</response>
+    /// <response code="204">No branding css configured.</response>
+    /// <returns>
+    /// An <see cref="OkResult"/> containing the branding css if exist,
+    /// or a <see cref="NoContentResult"/> if the css is not configured.
+    /// </returns>
+    [HttpGet("Css")]
+    [HttpGet("Css.css", Name = "GetBrandingCss_2")]
+    [Produces("text/css")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult<string> GetBrandingCss()
+    {
+        var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+        return options.CustomCss ?? string.Empty;
     }
 }

+ 207 - 209
Jellyfin.Api/Controllers/ChannelsController.cs

@@ -3,7 +3,6 @@ using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Threading;
 using System.Threading.Tasks;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Enums;
@@ -18,234 +17,233 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Channels Controller.
+/// </summary>
+[Authorize]
+public class ChannelsController : BaseJellyfinApiController
 {
+    private readonly IChannelManager _channelManager;
+    private readonly IUserManager _userManager;
+
     /// <summary>
-    /// Channels Controller.
+    /// Initializes a new instance of the <see cref="ChannelsController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class ChannelsController : BaseJellyfinApiController
+    /// <param name="channelManager">Instance of the <see cref="IChannelManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    public ChannelsController(IChannelManager channelManager, IUserManager userManager)
     {
-        private readonly IChannelManager _channelManager;
-        private readonly IUserManager _userManager;
+        _channelManager = channelManager;
+        _userManager = userManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ChannelsController"/> class.
-        /// </summary>
-        /// <param name="channelManager">Instance of the <see cref="IChannelManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        public ChannelsController(IChannelManager channelManager, IUserManager userManager)
+    /// <summary>
+    /// Gets available channels.
+    /// </summary>
+    /// <param name="userId">User Id to filter by. Use <see cref="Guid.Empty"/> to not filter by user.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="supportsLatestItems">Optional. Filter by channels that support getting latest items.</param>
+    /// <param name="supportsMediaDeletion">Optional. Filter by channels that support media deletion.</param>
+    /// <param name="isFavorite">Optional. Filter by channels that are favorite.</param>
+    /// <response code="200">Channels returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the channels.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetChannels(
+        [FromQuery] Guid? userId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] bool? supportsLatestItems,
+        [FromQuery] bool? supportsMediaDeletion,
+        [FromQuery] bool? isFavorite)
+    {
+        return _channelManager.GetChannels(new ChannelQuery
         {
-            _channelManager = channelManager;
-            _userManager = userManager;
-        }
+            Limit = limit,
+            StartIndex = startIndex,
+            UserId = userId ?? Guid.Empty,
+            SupportsLatestItems = supportsLatestItems,
+            SupportsMediaDeletion = supportsMediaDeletion,
+            IsFavorite = isFavorite
+        });
+    }
 
-        /// <summary>
-        /// Gets available channels.
-        /// </summary>
-        /// <param name="userId">User Id to filter by. Use <see cref="Guid.Empty"/> to not filter by user.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="supportsLatestItems">Optional. Filter by channels that support getting latest items.</param>
-        /// <param name="supportsMediaDeletion">Optional. Filter by channels that support media deletion.</param>
-        /// <param name="isFavorite">Optional. Filter by channels that are favorite.</param>
-        /// <response code="200">Channels returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the channels.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetChannels(
-            [FromQuery] Guid? userId,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit,
-            [FromQuery] bool? supportsLatestItems,
-            [FromQuery] bool? supportsMediaDeletion,
-            [FromQuery] bool? isFavorite)
-        {
-            return _channelManager.GetChannels(new ChannelQuery
-            {
-                Limit = limit,
-                StartIndex = startIndex,
-                UserId = userId ?? Guid.Empty,
-                SupportsLatestItems = supportsLatestItems,
-                SupportsMediaDeletion = supportsMediaDeletion,
-                IsFavorite = isFavorite
-            });
-        }
+    /// <summary>
+    /// Get all channel features.
+    /// </summary>
+    /// <response code="200">All channel features returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
+    [HttpGet("Features")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<ChannelFeatures>> GetAllChannelFeatures()
+    {
+        return _channelManager.GetAllChannelFeatures();
+    }
 
-        /// <summary>
-        /// Get all channel features.
-        /// </summary>
-        /// <response code="200">All channel features returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
-        [HttpGet("Features")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<ChannelFeatures>> GetAllChannelFeatures()
-        {
-            return _channelManager.GetAllChannelFeatures();
-        }
+    /// <summary>
+    /// Get channel features.
+    /// </summary>
+    /// <param name="channelId">Channel id.</param>
+    /// <response code="200">Channel features returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
+    [HttpGet("{channelId}/Features")]
+    public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId)
+    {
+        return _channelManager.GetChannelFeatures(channelId);
+    }
 
-        /// <summary>
-        /// Get channel features.
-        /// </summary>
-        /// <param name="channelId">Channel id.</param>
-        /// <response code="200">Channel features returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
-        [HttpGet("{channelId}/Features")]
-        public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId)
-        {
-            return _channelManager.GetChannelFeatures(channelId);
-        }
+    /// <summary>
+    /// Get channel items.
+    /// </summary>
+    /// <param name="channelId">Channel Id.</param>
+    /// <param name="folderId">Optional. Folder Id.</param>
+    /// <param name="userId">Optional. User Id.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <response code="200">Channel items returned.</response>
+    /// <returns>
+    /// A <see cref="Task"/> representing the request to get the channel items.
+    /// The task result contains an <see cref="OkResult"/> containing the channel items.
+    /// </returns>
+    [HttpGet("{channelId}/Items")]
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems(
+        [FromRoute, Required] Guid channelId,
+        [FromQuery] Guid? folderId,
+        [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))] string[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-        /// <summary>
-        /// Get channel items.
-        /// </summary>
-        /// <param name="channelId">Channel Id.</param>
-        /// <param name="folderId">Optional. Folder Id.</param>
-        /// <param name="userId">Optional. User Id.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <response code="200">Channel items returned.</response>
-        /// <returns>
-        /// A <see cref="Task"/> representing the request to get the channel items.
-        /// The task result contains an <see cref="OkResult"/> containing the channel items.
-        /// </returns>
-        [HttpGet("{channelId}/Items")]
-        public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems(
-            [FromRoute, Required] Guid channelId,
-            [FromQuery] Guid? folderId,
-            [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))] string[] sortBy,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
+        var query = new InternalItemsQuery(user)
         {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+            Limit = limit,
+            StartIndex = startIndex,
+            ChannelIds = new[] { channelId },
+            ParentId = folderId ?? Guid.Empty,
+            OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
+            DtoOptions = new DtoOptions { Fields = fields }
+        };
 
-            var query = new InternalItemsQuery(user)
-            {
-                Limit = limit,
-                StartIndex = startIndex,
-                ChannelIds = new[] { channelId },
-                ParentId = folderId ?? Guid.Empty,
-                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
-                DtoOptions = new DtoOptions { Fields = fields }
-            };
-
-            foreach (var filter in filters)
+        foreach (var filter in filters)
+        {
+            switch (filter)
             {
-                switch (filter)
-                {
-                    case ItemFilter.IsFolder:
-                        query.IsFolder = true;
-                        break;
-                    case ItemFilter.IsNotFolder:
-                        query.IsFolder = false;
-                        break;
-                    case ItemFilter.IsUnplayed:
-                        query.IsPlayed = false;
-                        break;
-                    case ItemFilter.IsPlayed:
-                        query.IsPlayed = true;
-                        break;
-                    case ItemFilter.IsFavorite:
-                        query.IsFavorite = true;
-                        break;
-                    case ItemFilter.IsResumable:
-                        query.IsResumable = true;
-                        break;
-                    case ItemFilter.Likes:
-                        query.IsLiked = true;
-                        break;
-                    case ItemFilter.Dislikes:
-                        query.IsLiked = false;
-                        break;
-                    case ItemFilter.IsFavoriteOrLikes:
-                        query.IsFavoriteOrLiked = true;
-                        break;
-                }
+                case ItemFilter.IsFolder:
+                    query.IsFolder = true;
+                    break;
+                case ItemFilter.IsNotFolder:
+                    query.IsFolder = false;
+                    break;
+                case ItemFilter.IsUnplayed:
+                    query.IsPlayed = false;
+                    break;
+                case ItemFilter.IsPlayed:
+                    query.IsPlayed = true;
+                    break;
+                case ItemFilter.IsFavorite:
+                    query.IsFavorite = true;
+                    break;
+                case ItemFilter.IsResumable:
+                    query.IsResumable = true;
+                    break;
+                case ItemFilter.Likes:
+                    query.IsLiked = true;
+                    break;
+                case ItemFilter.Dislikes:
+                    query.IsLiked = false;
+                    break;
+                case ItemFilter.IsFavoriteOrLikes:
+                    query.IsFavoriteOrLiked = true;
+                    break;
             }
-
-            return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false);
         }
 
-        /// <summary>
-        /// Gets latest channel items.
-        /// </summary>
-        /// <param name="userId">Optional. User Id.</param>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="filters">Optional. Specify additional filters to apply.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param>
-        /// <response code="200">Latest channel items returned.</response>
-        /// <returns>
-        /// A <see cref="Task"/> representing the request to get the latest channel items.
-        /// The task result contains an <see cref="OkResult"/> containing the latest channel items.
-        /// </returns>
-        [HttpGet("Items/Latest")]
-        public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLatestChannelItems(
-            [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)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+        return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false);
+    }
 
-            var query = new InternalItemsQuery(user)
-            {
-                Limit = limit,
-                StartIndex = startIndex,
-                ChannelIds = channelIds,
-                DtoOptions = new DtoOptions { Fields = fields }
-            };
+    /// <summary>
+    /// Gets latest channel items.
+    /// </summary>
+    /// <param name="userId">Optional. User Id.</param>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="filters">Optional. Specify additional filters to apply.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param>
+    /// <response code="200">Latest channel items returned.</response>
+    /// <returns>
+    /// A <see cref="Task"/> representing the request to get the latest channel items.
+    /// The task result contains an <see cref="OkResult"/> containing the latest channel items.
+    /// </returns>
+    [HttpGet("Items/Latest")]
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLatestChannelItems(
+        [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)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-            foreach (var filter in filters)
+        var query = new InternalItemsQuery(user)
+        {
+            Limit = limit,
+            StartIndex = startIndex,
+            ChannelIds = channelIds,
+            DtoOptions = new DtoOptions { Fields = fields }
+        };
+
+        foreach (var filter in filters)
+        {
+            switch (filter)
             {
-                switch (filter)
-                {
-                    case ItemFilter.IsFolder:
-                        query.IsFolder = true;
-                        break;
-                    case ItemFilter.IsNotFolder:
-                        query.IsFolder = false;
-                        break;
-                    case ItemFilter.IsUnplayed:
-                        query.IsPlayed = false;
-                        break;
-                    case ItemFilter.IsPlayed:
-                        query.IsPlayed = true;
-                        break;
-                    case ItemFilter.IsFavorite:
-                        query.IsFavorite = true;
-                        break;
-                    case ItemFilter.IsResumable:
-                        query.IsResumable = true;
-                        break;
-                    case ItemFilter.Likes:
-                        query.IsLiked = true;
-                        break;
-                    case ItemFilter.Dislikes:
-                        query.IsLiked = false;
-                        break;
-                    case ItemFilter.IsFavoriteOrLikes:
-                        query.IsFavoriteOrLiked = true;
-                        break;
-                }
+                case ItemFilter.IsFolder:
+                    query.IsFolder = true;
+                    break;
+                case ItemFilter.IsNotFolder:
+                    query.IsFolder = false;
+                    break;
+                case ItemFilter.IsUnplayed:
+                    query.IsPlayed = false;
+                    break;
+                case ItemFilter.IsPlayed:
+                    query.IsPlayed = true;
+                    break;
+                case ItemFilter.IsFavorite:
+                    query.IsFavorite = true;
+                    break;
+                case ItemFilter.IsResumable:
+                    query.IsResumable = true;
+                    break;
+                case ItemFilter.Likes:
+                    query.IsLiked = true;
+                    break;
+                case ItemFilter.Dislikes:
+                    query.IsLiked = false;
+                    break;
+                case ItemFilter.IsFavoriteOrLikes:
+                    query.IsFavoriteOrLiked = true;
+                    break;
             }
-
-            return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false);
         }
+
+        return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false);
     }
 }

+ 53 - 56
Jellyfin.Api/Controllers/ClientLogController.cs

@@ -1,9 +1,7 @@
 using System.Net.Mime;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
-using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Models.ClientLogDtos;
 using MediaBrowser.Controller.ClientEvent;
 using MediaBrowser.Controller.Configuration;
@@ -11,71 +9,70 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Client log controller.
+/// </summary>
+[Authorize]
+public class ClientLogController : BaseJellyfinApiController
 {
+    private const int MaxDocumentSize = 1_000_000;
+    private readonly IClientEventLogger _clientEventLogger;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+
     /// <summary>
-    /// Client log controller.
+    /// Initializes a new instance of the <see cref="ClientLogController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class ClientLogController : BaseJellyfinApiController
+    /// <param name="clientEventLogger">Instance of the <see cref="IClientEventLogger"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    public ClientLogController(
+        IClientEventLogger clientEventLogger,
+        IServerConfigurationManager serverConfigurationManager)
     {
-        private const int MaxDocumentSize = 1_000_000;
-        private readonly IClientEventLogger _clientEventLogger;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
+        _clientEventLogger = clientEventLogger;
+        _serverConfigurationManager = serverConfigurationManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ClientLogController"/> class.
-        /// </summary>
-        /// <param name="clientEventLogger">Instance of the <see cref="IClientEventLogger"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        public ClientLogController(
-            IClientEventLogger clientEventLogger,
-            IServerConfigurationManager serverConfigurationManager)
+    /// <summary>
+    /// Upload a document.
+    /// </summary>
+    /// <response code="200">Document saved.</response>
+    /// <response code="403">Event logging disabled.</response>
+    /// <response code="413">Upload size too large.</response>
+    /// <returns>Create response.</returns>
+    [HttpPost("Document")]
+    [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
+    [AcceptsFile(MediaTypeNames.Text.Plain)]
+    [RequestSizeLimit(MaxDocumentSize)]
+    public async Task<ActionResult<ClientLogDocumentResponseDto>> LogFile()
+    {
+        if (!_serverConfigurationManager.Configuration.AllowClientLogUpload)
         {
-            _clientEventLogger = clientEventLogger;
-            _serverConfigurationManager = serverConfigurationManager;
+            return Forbid();
         }
 
-        /// <summary>
-        /// Upload a document.
-        /// </summary>
-        /// <response code="200">Document saved.</response>
-        /// <response code="403">Event logging disabled.</response>
-        /// <response code="413">Upload size too large.</response>
-        /// <returns>Create response.</returns>
-        [HttpPost("Document")]
-        [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
-        [AcceptsFile(MediaTypeNames.Text.Plain)]
-        [RequestSizeLimit(MaxDocumentSize)]
-        public async Task<ActionResult<ClientLogDocumentResponseDto>> LogFile()
+        if (Request.ContentLength > MaxDocumentSize)
         {
-            if (!_serverConfigurationManager.Configuration.AllowClientLogUpload)
-            {
-                return Forbid();
-            }
-
-            if (Request.ContentLength > MaxDocumentSize)
-            {
-                // Manually validate to return proper status code.
-                return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes");
-            }
-
-            var (clientName, clientVersion) = GetRequestInformation();
-            var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body)
-                .ConfigureAwait(false);
-            return Ok(new ClientLogDocumentResponseDto(fileName));
+            // Manually validate to return proper status code.
+            return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes");
         }
 
-        private (string ClientName, string ClientVersion) GetRequestInformation()
-        {
-            var clientName = HttpContext.User.GetClient() ?? "unknown-client";
-            var clientVersion = HttpContext.User.GetIsApiKey()
-                ? "apikey"
-                : HttpContext.User.GetVersion() ?? "unknown-version";
+        var (clientName, clientVersion) = GetRequestInformation();
+        var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body)
+            .ConfigureAwait(false);
+        return Ok(new ClientLogDocumentResponseDto(fileName));
+    }
 
-            return (clientName, clientVersion);
-        }
+    private (string ClientName, string ClientVersion) GetRequestInformation()
+    {
+        var clientName = HttpContext.User.GetClient() ?? "unknown-client";
+        var clientVersion = HttpContext.User.GetIsApiKey()
+            ? "apikey"
+            : HttpContext.User.GetVersion() ?? "unknown-version";
+
+        return (clientName, clientVersion);
     }
 }

+ 83 - 84
Jellyfin.Api/Controllers/CollectionController.cs

@@ -11,101 +11,100 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The collection controller.
+/// </summary>
+[Route("Collections")]
+[Authorize(Policy = Policies.CollectionManagement)]
+public class CollectionController : BaseJellyfinApiController
 {
+    private readonly ICollectionManager _collectionManager;
+    private readonly IDtoService _dtoService;
+
     /// <summary>
-    /// The collection controller.
+    /// Initializes a new instance of the <see cref="CollectionController"/> class.
     /// </summary>
-    [Route("Collections")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class CollectionController : BaseJellyfinApiController
+    /// <param name="collectionManager">Instance of <see cref="ICollectionManager"/> interface.</param>
+    /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
+    public CollectionController(
+        ICollectionManager collectionManager,
+        IDtoService dtoService)
     {
-        private readonly ICollectionManager _collectionManager;
-        private readonly IDtoService _dtoService;
+        _collectionManager = collectionManager;
+        _dtoService = dtoService;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CollectionController"/> class.
-        /// </summary>
-        /// <param name="collectionManager">Instance of <see cref="ICollectionManager"/> interface.</param>
-        /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
-        public CollectionController(
-            ICollectionManager collectionManager,
-            IDtoService dtoService)
-        {
-            _collectionManager = collectionManager;
-            _dtoService = dtoService;
-        }
+    /// <summary>
+    /// Creates a new collection.
+    /// </summary>
+    /// <param name="name">The name of the collection.</param>
+    /// <param name="ids">Item Ids to add to the collection.</param>
+    /// <param name="parentId">Optional. Create the collection within a specific folder.</param>
+    /// <param name="isLocked">Whether or not to lock the new collection.</param>
+    /// <response code="200">Collection created.</response>
+    /// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns>
+    [HttpPost]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<CollectionCreationResult>> CreateCollection(
+        [FromQuery] string? name,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids,
+        [FromQuery] Guid? parentId,
+        [FromQuery] bool isLocked = false)
+    {
+        var userId = User.GetUserId();
 
-        /// <summary>
-        /// Creates a new collection.
-        /// </summary>
-        /// <param name="name">The name of the collection.</param>
-        /// <param name="ids">Item Ids to add to the collection.</param>
-        /// <param name="parentId">Optional. Create the collection within a specific folder.</param>
-        /// <param name="isLocked">Whether or not to lock the new collection.</param>
-        /// <response code="200">Collection created.</response>
-        /// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns>
-        [HttpPost]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<CollectionCreationResult>> CreateCollection(
-            [FromQuery] string? name,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids,
-            [FromQuery] Guid? parentId,
-            [FromQuery] bool isLocked = false)
+        var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions
         {
-            var userId = User.GetUserId();
-
-            var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions
-            {
-                IsLocked = isLocked,
-                Name = name,
-                ParentId = parentId,
-                ItemIdList = ids,
-                UserIds = new[] { userId }
-            }).ConfigureAwait(false);
+            IsLocked = isLocked,
+            Name = name,
+            ParentId = parentId,
+            ItemIdList = ids,
+            UserIds = new[] { userId }
+        }).ConfigureAwait(false);
 
-            var dtoOptions = new DtoOptions().AddClientFields(User);
+        var dtoOptions = new DtoOptions().AddClientFields(User);
 
-            var dto = _dtoService.GetBaseItemDto(item, dtoOptions);
+        var dto = _dtoService.GetBaseItemDto(item, dtoOptions);
 
-            return new CollectionCreationResult
-            {
-                Id = dto.Id
-            };
-        }
-
-        /// <summary>
-        /// Adds items to a collection.
-        /// </summary>
-        /// <param name="collectionId">The collection id.</param>
-        /// <param name="ids">Item ids, comma delimited.</param>
-        /// <response code="204">Items added to collection.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("{collectionId}/Items")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> AddToCollection(
-            [FromRoute, Required] Guid collectionId,
-            [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+        return new CollectionCreationResult
         {
-            await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
-            return NoContent();
-        }
+            Id = dto.Id
+        };
+    }
 
-        /// <summary>
-        /// Removes items from a collection.
-        /// </summary>
-        /// <param name="collectionId">The collection id.</param>
-        /// <param name="ids">Item ids, comma delimited.</param>
-        /// <response code="204">Items removed from collection.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpDelete("{collectionId}/Items")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> RemoveFromCollection(
-            [FromRoute, Required] Guid collectionId,
-            [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
-        {
-            await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false);
-            return NoContent();
-        }
+    /// <summary>
+    /// Adds items to a collection.
+    /// </summary>
+    /// <param name="collectionId">The collection id.</param>
+    /// <param name="ids">Item ids, comma delimited.</param>
+    /// <response code="204">Items added to collection.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("{collectionId}/Items")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> AddToCollection(
+        [FromRoute, Required] Guid collectionId,
+        [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+    {
+        await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Removes items from a collection.
+    /// </summary>
+    /// <param name="collectionId">The collection id.</param>
+    /// <param name="ids">Item ids, comma delimited.</param>
+    /// <response code="204">Items removed from collection.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpDelete("{collectionId}/Items")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> RemoveFromCollection(
+        [FromRoute, Required] Guid collectionId,
+        [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
+    {
+        await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false);
+        return NoContent();
     }
 }

+ 104 - 105
Jellyfin.Api/Controllers/ConfigurationController.cs

@@ -13,124 +13,123 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Configuration Controller.
+/// </summary>
+[Route("System")]
+[Authorize]
+public class ConfigurationController : BaseJellyfinApiController
 {
+    private readonly IServerConfigurationManager _configurationManager;
+    private readonly IMediaEncoder _mediaEncoder;
+
+    private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options;
+
     /// <summary>
-    /// Configuration Controller.
+    /// Initializes a new instance of the <see cref="ConfigurationController"/> class.
     /// </summary>
-    [Route("System")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class ConfigurationController : BaseJellyfinApiController
+    /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+    public ConfigurationController(
+        IServerConfigurationManager configurationManager,
+        IMediaEncoder mediaEncoder)
     {
-        private readonly IServerConfigurationManager _configurationManager;
-        private readonly IMediaEncoder _mediaEncoder;
+        _configurationManager = configurationManager;
+        _mediaEncoder = mediaEncoder;
+    }
 
-        private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options;
+    /// <summary>
+    /// Gets application configuration.
+    /// </summary>
+    /// <response code="200">Application configuration returned.</response>
+    /// <returns>Application configuration.</returns>
+    [HttpGet("Configuration")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<ServerConfiguration> GetConfiguration()
+    {
+        return _configurationManager.Configuration;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ConfigurationController"/> class.
-        /// </summary>
-        /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
-        public ConfigurationController(
-            IServerConfigurationManager configurationManager,
-            IMediaEncoder mediaEncoder)
-        {
-            _configurationManager = configurationManager;
-            _mediaEncoder = mediaEncoder;
-        }
+    /// <summary>
+    /// Updates application configuration.
+    /// </summary>
+    /// <param name="configuration">Configuration.</param>
+    /// <response code="204">Configuration updated.</response>
+    /// <returns>Update status.</returns>
+    [HttpPost("Configuration")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration)
+    {
+        _configurationManager.ReplaceConfiguration(configuration);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Gets application configuration.
-        /// </summary>
-        /// <response code="200">Application configuration returned.</response>
-        /// <returns>Application configuration.</returns>
-        [HttpGet("Configuration")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<ServerConfiguration> GetConfiguration()
-        {
-            return _configurationManager.Configuration;
-        }
+    /// <summary>
+    /// Gets a named configuration.
+    /// </summary>
+    /// <param name="key">Configuration key.</param>
+    /// <response code="200">Configuration returned.</response>
+    /// <returns>Configuration.</returns>
+    [HttpGet("Configuration/{key}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesFile(MediaTypeNames.Application.Json)]
+    public ActionResult<object> GetNamedConfiguration([FromRoute, Required] string key)
+    {
+        return _configurationManager.GetConfiguration(key);
+    }
 
-        /// <summary>
-        /// Updates application configuration.
-        /// </summary>
-        /// <param name="configuration">Configuration.</param>
-        /// <response code="204">Configuration updated.</response>
-        /// <returns>Update status.</returns>
-        [HttpPost("Configuration")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration)
-        {
-            _configurationManager.ReplaceConfiguration(configuration);
-            return NoContent();
-        }
+    /// <summary>
+    /// Updates named configuration.
+    /// </summary>
+    /// <param name="key">Configuration key.</param>
+    /// <param name="configuration">Configuration.</param>
+    /// <response code="204">Named configuration updated.</response>
+    /// <returns>Update status.</returns>
+    [HttpPost("Configuration/{key}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult UpdateNamedConfiguration([FromRoute, Required] string key, [FromBody, Required] JsonDocument configuration)
+    {
+        var configurationType = _configurationManager.GetConfigurationType(key);
+        var deserializedConfiguration = configuration.Deserialize(configurationType, _serializerOptions);
 
-        /// <summary>
-        /// Gets a named configuration.
-        /// </summary>
-        /// <param name="key">Configuration key.</param>
-        /// <response code="200">Configuration returned.</response>
-        /// <returns>Configuration.</returns>
-        [HttpGet("Configuration/{key}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesFile(MediaTypeNames.Application.Json)]
-        public ActionResult<object> GetNamedConfiguration([FromRoute, Required] string key)
+        if (deserializedConfiguration is null)
         {
-            return _configurationManager.GetConfiguration(key);
+            throw new ArgumentException("Body doesn't contain a valid configuration");
         }
 
-        /// <summary>
-        /// Updates named configuration.
-        /// </summary>
-        /// <param name="key">Configuration key.</param>
-        /// <param name="configuration">Configuration.</param>
-        /// <response code="204">Named configuration updated.</response>
-        /// <returns>Update status.</returns>
-        [HttpPost("Configuration/{key}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult UpdateNamedConfiguration([FromRoute, Required] string key, [FromBody, Required] JsonDocument configuration)
-        {
-            var configurationType = _configurationManager.GetConfigurationType(key);
-            var deserializedConfiguration = configuration.Deserialize(configurationType, _serializerOptions);
-
-            if (deserializedConfiguration is null)
-            {
-                throw new ArgumentException("Body doesn't contain a valid configuration");
-            }
-
-            _configurationManager.SaveConfiguration(key, deserializedConfiguration);
-            return NoContent();
-        }
+        _configurationManager.SaveConfiguration(key, deserializedConfiguration);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Gets a default MetadataOptions object.
-        /// </summary>
-        /// <response code="200">Metadata options returned.</response>
-        /// <returns>Default MetadataOptions.</returns>
-        [HttpGet("Configuration/MetadataOptions/Default")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<MetadataOptions> GetDefaultMetadataOptions()
-        {
-            return new MetadataOptions();
-        }
+    /// <summary>
+    /// Gets a default MetadataOptions object.
+    /// </summary>
+    /// <response code="200">Metadata options returned.</response>
+    /// <returns>Default MetadataOptions.</returns>
+    [HttpGet("Configuration/MetadataOptions/Default")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<MetadataOptions> GetDefaultMetadataOptions()
+    {
+        return new MetadataOptions();
+    }
 
-        /// <summary>
-        /// Updates the path to the media encoder.
-        /// </summary>
-        /// <param name="mediaEncoderPath">Media encoder path form body.</param>
-        /// <response code="204">Media encoder path updated.</response>
-        /// <returns>Status.</returns>
-        [HttpPost("MediaEncoder/Path")]
-        [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath)
-        {
-            _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
-            return NoContent();
-        }
+    /// <summary>
+    /// Updates the path to the media encoder.
+    /// </summary>
+    /// <param name="mediaEncoderPath">Media encoder path form body.</param>
+    /// <response code="204">Media encoder path updated.</response>
+    /// <returns>Status.</returns>
+    [HttpPost("MediaEncoder/Path")]
+    [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath)
+    {
+        _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
+        return NoContent();
     }
 }

+ 78 - 80
Jellyfin.Api/Controllers/DashboardController.cs

@@ -4,7 +4,6 @@ using System.IO;
 using System.Linq;
 using System.Net.Mime;
 using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Models;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Model.Net;
@@ -14,103 +13,102 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The dashboard controller.
+/// </summary>
+[Route("")]
+public class DashboardController : BaseJellyfinApiController
 {
+    private readonly ILogger<DashboardController> _logger;
+    private readonly IPluginManager _pluginManager;
+
     /// <summary>
-    /// The dashboard controller.
+    /// Initializes a new instance of the <see cref="DashboardController"/> class.
     /// </summary>
-    [Route("")]
-    public class DashboardController : BaseJellyfinApiController
+    /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param>
+    /// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param>
+    public DashboardController(
+        ILogger<DashboardController> logger,
+        IPluginManager pluginManager)
     {
-        private readonly ILogger<DashboardController> _logger;
-        private readonly IPluginManager _pluginManager;
+        _logger = logger;
+        _pluginManager = pluginManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DashboardController"/> class.
-        /// </summary>
-        /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param>
-        /// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param>
-        public DashboardController(
-            ILogger<DashboardController> logger,
-            IPluginManager pluginManager)
-        {
-            _logger = logger;
-            _pluginManager = pluginManager;
-        }
+    /// <summary>
+    /// Gets the configuration pages.
+    /// </summary>
+    /// <param name="enableInMainMenu">Whether to enable in the main menu.</param>
+    /// <response code="200">ConfigurationPages returned.</response>
+    /// <response code="404">Server still loading.</response>
+    /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns>
+    [HttpGet("web/ConfigurationPages")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Authorize]
+    public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages(
+        [FromQuery] bool? enableInMainMenu)
+    {
+        var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList();
 
-        /// <summary>
-        /// Gets the configuration pages.
-        /// </summary>
-        /// <param name="enableInMainMenu">Whether to enable in the main menu.</param>
-        /// <response code="200">ConfigurationPages returned.</response>
-        /// <response code="404">Server still loading.</response>
-        /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns>
-        [HttpGet("web/ConfigurationPages")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages(
-            [FromQuery] bool? enableInMainMenu)
+        if (enableInMainMenu.HasValue)
         {
-            var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList();
-
-            if (enableInMainMenu.HasValue)
-            {
-                configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList();
-            }
-
-            return configPages;
+            configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList();
         }
 
-        /// <summary>
-        /// Gets a dashboard configuration page.
-        /// </summary>
-        /// <param name="name">The name of the page.</param>
-        /// <response code="200">ConfigurationPage returned.</response>
-        /// <response code="404">Plugin configuration page not found.</response>
-        /// <returns>The configuration page.</returns>
-        [HttpGet("web/ConfigurationPage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")]
-        public ActionResult GetDashboardConfigurationPage([FromQuery] string? name)
-        {
-            var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase));
-            if (altPage is null)
-            {
-                return NotFound();
-            }
-
-            IPlugin plugin = altPage.Item2;
-            string resourcePath = altPage.Item1.EmbeddedResourcePath;
-            Stream? stream = plugin.GetType().Assembly.GetManifestResourceStream(resourcePath);
-            if (stream is null)
-            {
-                _logger.LogError("Failed to get resource {Resource} from plugin {Plugin}", resourcePath, plugin.Name);
-                return NotFound();
-            }
+        return configPages;
+    }
 
-            return File(stream, MimeTypes.GetMimeType(resourcePath));
+    /// <summary>
+    /// Gets a dashboard configuration page.
+    /// </summary>
+    /// <param name="name">The name of the page.</param>
+    /// <response code="200">ConfigurationPage returned.</response>
+    /// <response code="404">Plugin configuration page not found.</response>
+    /// <returns>The configuration page.</returns>
+    [HttpGet("web/ConfigurationPage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")]
+    public ActionResult GetDashboardConfigurationPage([FromQuery] string? name)
+    {
+        var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase));
+        if (altPage is null)
+        {
+            return NotFound();
         }
 
-        private IEnumerable<ConfigurationPageInfo> GetConfigPages(LocalPlugin plugin)
+        IPlugin plugin = altPage.Item2;
+        string resourcePath = altPage.Item1.EmbeddedResourcePath;
+        Stream? stream = plugin.GetType().Assembly.GetManifestResourceStream(resourcePath);
+        if (stream is null)
         {
-            return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1));
+            _logger.LogError("Failed to get resource {Resource} from plugin {Plugin}", resourcePath, plugin.Name);
+            return NotFound();
         }
 
-        private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(LocalPlugin plugin)
-        {
-            if (plugin.Instance is not IHasWebPages hasWebPages)
-            {
-                return Enumerable.Empty<Tuple<PluginPageInfo, IPlugin>>();
-            }
+        return File(stream, MimeTypes.GetMimeType(resourcePath));
+    }
 
-            return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin.Instance));
-        }
+    private IEnumerable<ConfigurationPageInfo> GetConfigPages(LocalPlugin plugin)
+    {
+        return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1));
+    }
 
-        private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages()
+    private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(LocalPlugin plugin)
+    {
+        if (plugin.Instance is not IHasWebPages hasWebPages)
         {
-            return _pluginManager.Plugins.SelectMany(GetPluginPages);
+            return Enumerable.Empty<Tuple<PluginPageInfo, IPlugin>>();
         }
+
+        return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin.Instance));
+    }
+
+    private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages()
+    {
+        return _pluginManager.Plugins.SelectMany(GetPluginPages);
     }
 }

+ 104 - 105
Jellyfin.Api/Controllers/DevicesController.cs

@@ -13,129 +13,128 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Devices Controller.
+/// </summary>
+[Authorize(Policy = Policies.RequiresElevation)]
+public class DevicesController : BaseJellyfinApiController
 {
+    private readonly IDeviceManager _deviceManager;
+    private readonly ISessionManager _sessionManager;
+
     /// <summary>
-    /// Devices Controller.
+    /// Initializes a new instance of the <see cref="DevicesController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.RequiresElevation)]
-    public class DevicesController : BaseJellyfinApiController
+    /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
+    /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
+    public DevicesController(
+        IDeviceManager deviceManager,
+        ISessionManager sessionManager)
     {
-        private readonly IDeviceManager _deviceManager;
-        private readonly ISessionManager _sessionManager;
+        _deviceManager = deviceManager;
+        _sessionManager = sessionManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DevicesController"/> class.
-        /// </summary>
-        /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
-        /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
-        public DevicesController(
-            IDeviceManager deviceManager,
-            ISessionManager sessionManager)
-        {
-            _deviceManager = deviceManager;
-            _sessionManager = sessionManager;
-        }
+    /// <summary>
+    /// Get Devices.
+    /// </summary>
+    /// <param name="supportsSync">Gets or sets a value indicating whether [supports synchronize].</param>
+    /// <param name="userId">Gets or sets the user identifier.</param>
+    /// <response code="200">Devices retrieved.</response>
+    /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<QueryResult<DeviceInfo>>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
+    {
+        return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Get Devices.
-        /// </summary>
-        /// <param name="supportsSync">Gets or sets a value indicating whether [supports synchronize].</param>
-        /// <param name="userId">Gets or sets the user identifier.</param>
-        /// <response code="200">Devices retrieved.</response>
-        /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<QueryResult<DeviceInfo>>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
+    /// <summary>
+    /// Get info for a device.
+    /// </summary>
+    /// <param name="id">Device Id.</param>
+    /// <response code="200">Device info retrieved.</response>
+    /// <response code="404">Device not found.</response>
+    /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+    [HttpGet("Info")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<DeviceInfo>> GetDeviceInfo([FromQuery, Required] string id)
+    {
+        var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false);
+        if (deviceInfo is null)
         {
-            return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false);
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get info for a device.
-        /// </summary>
-        /// <param name="id">Device Id.</param>
-        /// <response code="200">Device info retrieved.</response>
-        /// <response code="404">Device not found.</response>
-        /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
-        [HttpGet("Info")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult<DeviceInfo>> GetDeviceInfo([FromQuery, Required] string id)
-        {
-            var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false);
-            if (deviceInfo is null)
-            {
-                return NotFound();
-            }
+        return deviceInfo;
+    }
 
-            return deviceInfo;
+    /// <summary>
+    /// Get options for a device.
+    /// </summary>
+    /// <param name="id">Device Id.</param>
+    /// <response code="200">Device options retrieved.</response>
+    /// <response code="404">Device not found.</response>
+    /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+    [HttpGet("Options")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<DeviceOptions>> GetDeviceOptions([FromQuery, Required] string id)
+    {
+        var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false);
+        if (deviceInfo is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get options for a device.
-        /// </summary>
-        /// <param name="id">Device Id.</param>
-        /// <response code="200">Device options retrieved.</response>
-        /// <response code="404">Device not found.</response>
-        /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
-        [HttpGet("Options")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult<DeviceOptions>> GetDeviceOptions([FromQuery, Required] string id)
-        {
-            var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false);
-            if (deviceInfo is null)
-            {
-                return NotFound();
-            }
+        return deviceInfo;
+    }
 
-            return deviceInfo;
-        }
+    /// <summary>
+    /// Update device options.
+    /// </summary>
+    /// <param name="id">Device Id.</param>
+    /// <param name="deviceOptions">Device Options.</param>
+    /// <response code="204">Device options updated.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Options")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> UpdateDeviceOptions(
+        [FromQuery, Required] string id,
+        [FromBody, Required] DeviceOptionsDto deviceOptions)
+    {
+        await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Update device options.
-        /// </summary>
-        /// <param name="id">Device Id.</param>
-        /// <param name="deviceOptions">Device Options.</param>
-        /// <response code="204">Device options updated.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Options")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> UpdateDeviceOptions(
-            [FromQuery, Required] string id,
-            [FromBody, Required] DeviceOptionsDto deviceOptions)
+    /// <summary>
+    /// Deletes a device.
+    /// </summary>
+    /// <param name="id">Device Id.</param>
+    /// <response code="204">Device deleted.</response>
+    /// <response code="404">Device not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+    [HttpDelete]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id)
+    {
+        var existingDevice = await _deviceManager.GetDevice(id).ConfigureAwait(false);
+        if (existingDevice is null)
         {
-            await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false);
-            return NoContent();
+            return NotFound();
         }
 
-        /// <summary>
-        /// Deletes a device.
-        /// </summary>
-        /// <param name="id">Device Id.</param>
-        /// <response code="204">Device deleted.</response>
-        /// <response code="404">Device not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
-        [HttpDelete]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id)
-        {
-            var existingDevice = await _deviceManager.GetDevice(id).ConfigureAwait(false);
-            if (existingDevice is null)
-            {
-                return NotFound();
-            }
-
-            var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }).ConfigureAwait(false);
-
-            foreach (var session in sessions.Items)
-            {
-                await _sessionManager.Logout(session).ConfigureAwait(false);
-            }
+        var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }).ConfigureAwait(false);
 
-            return NoContent();
+        foreach (var session in sessions.Items)
+        {
+            await _sessionManager.Logout(session).ConfigureAwait(false);
         }
+
+        return NoContent();
     }
 }

+ 170 - 172
Jellyfin.Api/Controllers/DisplayPreferencesController.cs

@@ -3,7 +3,6 @@ using System.ComponentModel.DataAnnotations;
 using System.Diagnostics.CodeAnalysis;
 using System.Globalization;
 using System.Linq;
-using Jellyfin.Api.Constants;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
@@ -14,201 +13,200 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Display Preferences Controller.
+/// </summary>
+[Authorize]
+public class DisplayPreferencesController : BaseJellyfinApiController
 {
+    private readonly IDisplayPreferencesManager _displayPreferencesManager;
+    private readonly ILogger<DisplayPreferencesController> _logger;
+
     /// <summary>
-    /// Display Preferences Controller.
+    /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class DisplayPreferencesController : BaseJellyfinApiController
+    /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param>
+    /// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param>
+    public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger)
     {
-        private readonly IDisplayPreferencesManager _displayPreferencesManager;
-        private readonly ILogger<DisplayPreferencesController> _logger;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class.
-        /// </summary>
-        /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param>
-        /// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param>
-        public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger)
+        _displayPreferencesManager = displayPreferencesManager;
+        _logger = logger;
+    }
+
+    /// <summary>
+    /// Get Display Preferences.
+    /// </summary>
+    /// <param name="displayPreferencesId">Display preferences id.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="client">Client.</param>
+    /// <response code="200">Display preferences retrieved.</response>
+    /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns>
+    [HttpGet("{displayPreferencesId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
+    public ActionResult<DisplayPreferencesDto> GetDisplayPreferences(
+        [FromRoute, Required] string displayPreferencesId,
+        [FromQuery, Required] Guid userId,
+        [FromQuery, Required] string client)
+    {
+        if (!Guid.TryParse(displayPreferencesId, out var itemId))
         {
-            _displayPreferencesManager = displayPreferencesManager;
-            _logger = logger;
+            itemId = displayPreferencesId.GetMD5();
         }
 
-        /// <summary>
-        /// Get Display Preferences.
-        /// </summary>
-        /// <param name="displayPreferencesId">Display preferences id.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="client">Client.</param>
-        /// <response code="200">Display preferences retrieved.</response>
-        /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns>
-        [HttpGet("{displayPreferencesId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
-        public ActionResult<DisplayPreferencesDto> GetDisplayPreferences(
-            [FromRoute, Required] string displayPreferencesId,
-            [FromQuery, Required] Guid userId,
-            [FromQuery, Required] string client)
-        {
-            if (!Guid.TryParse(displayPreferencesId, out var itemId))
-            {
-                itemId = displayPreferencesId.GetMD5();
-            }
+        var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
+        var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
+        itemPreferences.ItemId = itemId;
 
-            var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
-            var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
-            itemPreferences.ItemId = itemId;
+        var dto = new DisplayPreferencesDto
+        {
+            Client = displayPreferences.Client,
+            Id = displayPreferences.ItemId.ToString(),
+            SortBy = itemPreferences.SortBy,
+            SortOrder = itemPreferences.SortOrder,
+            IndexBy = displayPreferences.IndexBy?.ToString(),
+            RememberIndexing = itemPreferences.RememberIndexing,
+            RememberSorting = itemPreferences.RememberSorting,
+            ScrollDirection = displayPreferences.ScrollDirection,
+            ShowBackdrop = displayPreferences.ShowBackdrop,
+            ShowSidebar = displayPreferences.ShowSidebar
+        };
+
+        foreach (var homeSection in displayPreferences.HomeSections)
+        {
+            dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant();
+        }
 
-            var dto = new DisplayPreferencesDto
-            {
-                Client = displayPreferences.Client,
-                Id = displayPreferences.ItemId.ToString(),
-                SortBy = itemPreferences.SortBy,
-                SortOrder = itemPreferences.SortOrder,
-                IndexBy = displayPreferences.IndexBy?.ToString(),
-                RememberIndexing = itemPreferences.RememberIndexing,
-                RememberSorting = itemPreferences.RememberSorting,
-                ScrollDirection = displayPreferences.ScrollDirection,
-                ShowBackdrop = displayPreferences.ShowBackdrop,
-                ShowSidebar = displayPreferences.ShowSidebar
-            };
-
-            foreach (var homeSection in displayPreferences.HomeSections)
-            {
-                dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant();
-            }
+        dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant();
+        dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture);
+        dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture);
+        dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
+        dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
+        dto.CustomPrefs["dashboardTheme"] = displayPreferences.DashboardTheme;
 
-            dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant();
-            dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture);
-            dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture);
-            dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
-            dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
-            dto.CustomPrefs["dashboardTheme"] = displayPreferences.DashboardTheme;
+        // Load all custom display preferences
+        var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
+        foreach (var (key, value) in customDisplayPreferences)
+        {
+            dto.CustomPrefs.TryAdd(key, value);
+        }
 
-            // Load all custom display preferences
-            var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
-            foreach (var (key, value) in customDisplayPreferences)
-            {
-                dto.CustomPrefs.TryAdd(key, value);
-            }
+        // This will essentially be a noop if no changes have been made, but new prefs must be saved at least.
+        _displayPreferencesManager.SaveChanges();
 
-            // This will essentially be a noop if no changes have been made, but new prefs must be saved at least.
-            _displayPreferencesManager.SaveChanges();
+        return dto;
+    }
 
-            return dto;
+    /// <summary>
+    /// Update Display Preferences.
+    /// </summary>
+    /// <param name="displayPreferencesId">Display preferences id.</param>
+    /// <param name="userId">User Id.</param>
+    /// <param name="client">Client.</param>
+    /// <param name="displayPreferences">New Display Preferences object.</param>
+    /// <response code="204">Display preferences updated.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success.</returns>
+    [HttpPost("{displayPreferencesId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
+    public ActionResult UpdateDisplayPreferences(
+        [FromRoute, Required] string displayPreferencesId,
+        [FromQuery, Required] Guid userId,
+        [FromQuery, Required] string client,
+        [FromBody, Required] DisplayPreferencesDto displayPreferences)
+    {
+        HomeSectionType[] defaults =
+        {
+            HomeSectionType.SmallLibraryTiles,
+            HomeSectionType.Resume,
+            HomeSectionType.ResumeAudio,
+            HomeSectionType.ResumeBook,
+            HomeSectionType.LiveTv,
+            HomeSectionType.NextUp,
+            HomeSectionType.LatestMedia,
+            HomeSectionType.None,
+        };
+
+        if (!Guid.TryParse(displayPreferencesId, out var itemId))
+        {
+            itemId = displayPreferencesId.GetMD5();
         }
 
-        /// <summary>
-        /// Update Display Preferences.
-        /// </summary>
-        /// <param name="displayPreferencesId">Display preferences id.</param>
-        /// <param name="userId">User Id.</param>
-        /// <param name="client">Client.</param>
-        /// <param name="displayPreferences">New Display Preferences object.</param>
-        /// <response code="204">Display preferences updated.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success.</returns>
-        [HttpPost("{displayPreferencesId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
-        public ActionResult UpdateDisplayPreferences(
-            [FromRoute, Required] string displayPreferencesId,
-            [FromQuery, Required] Guid userId,
-            [FromQuery, Required] string client,
-            [FromBody, Required] DisplayPreferencesDto displayPreferences)
+        var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
+        existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null;
+        existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop;
+        existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar;
+
+        existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection;
+        existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion)
+                                                       && !string.IsNullOrEmpty(chromecastVersion)
+            ? Enum.Parse<ChromecastVersion>(chromecastVersion, true)
+            : ChromecastVersion.Stable;
+        displayPreferences.CustomPrefs.Remove("chromecastVersion");
+
+        existingDisplayPreferences.EnableNextVideoInfoOverlay = !displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay)
+                                                                || string.IsNullOrEmpty(enableNextVideoInfoOverlay)
+                                                                || bool.Parse(enableNextVideoInfoOverlay);
+        displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay");
+
+        existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength)
+                                                        && !string.IsNullOrEmpty(skipBackLength)
+            ? int.Parse(skipBackLength, CultureInfo.InvariantCulture)
+            : 10000;
+        displayPreferences.CustomPrefs.Remove("skipBackLength");
+
+        existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength)
+                                                       && !string.IsNullOrEmpty(skipForwardLength)
+            ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture)
+            : 30000;
+        displayPreferences.CustomPrefs.Remove("skipForwardLength");
+
+        existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme)
+            ? theme
+            : string.Empty;
+        displayPreferences.CustomPrefs.Remove("dashboardTheme");
+
+        existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home)
+            ? home
+            : string.Empty;
+        displayPreferences.CustomPrefs.Remove("tvhome");
+
+        existingDisplayPreferences.HomeSections.Clear();
+
+        foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase)))
         {
-            HomeSectionType[] defaults =
-            {
-                HomeSectionType.SmallLibraryTiles,
-                HomeSectionType.Resume,
-                HomeSectionType.ResumeAudio,
-                HomeSectionType.ResumeBook,
-                HomeSectionType.LiveTv,
-                HomeSectionType.NextUp,
-                HomeSectionType.LatestMedia,
-                HomeSectionType.None,
-            };
-
-            if (!Guid.TryParse(displayPreferencesId, out var itemId))
+            var order = int.Parse(key.AsSpan().Slice("homesection".Length), CultureInfo.InvariantCulture);
+            if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type))
             {
-                itemId = displayPreferencesId.GetMD5();
+                type = order < 8 ? defaults[order] : HomeSectionType.None;
             }
 
-            var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
-            existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null;
-            existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop;
-            existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar;
-
-            existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection;
-            existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion)
-                                                           && !string.IsNullOrEmpty(chromecastVersion)
-                ? Enum.Parse<ChromecastVersion>(chromecastVersion, true)
-                : ChromecastVersion.Stable;
-            displayPreferences.CustomPrefs.Remove("chromecastVersion");
-
-            existingDisplayPreferences.EnableNextVideoInfoOverlay = !displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay)
-                                                                    || string.IsNullOrEmpty(enableNextVideoInfoOverlay)
-                                                                    || bool.Parse(enableNextVideoInfoOverlay);
-            displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay");
-
-            existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength)
-                                                            && !string.IsNullOrEmpty(skipBackLength)
-                ? int.Parse(skipBackLength, CultureInfo.InvariantCulture)
-                : 10000;
-            displayPreferences.CustomPrefs.Remove("skipBackLength");
-
-            existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength)
-                                                           && !string.IsNullOrEmpty(skipForwardLength)
-                ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture)
-                : 30000;
-            displayPreferences.CustomPrefs.Remove("skipForwardLength");
-
-            existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme)
-                ? theme
-                : string.Empty;
-            displayPreferences.CustomPrefs.Remove("dashboardTheme");
-
-            existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home)
-                ? home
-                : string.Empty;
-            displayPreferences.CustomPrefs.Remove("tvhome");
-
-            existingDisplayPreferences.HomeSections.Clear();
-
-            foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase)))
-            {
-                var order = int.Parse(key.AsSpan().Slice("homesection".Length), CultureInfo.InvariantCulture);
-                if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type))
-                {
-                    type = order < 8 ? defaults[order] : HomeSectionType.None;
-                }
-
-                displayPreferences.CustomPrefs.Remove(key);
-                existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type });
-            }
+            displayPreferences.CustomPrefs.Remove(key);
+            existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type });
+        }
 
-            foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
+        foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
+        {
+            if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type))
             {
-                if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type))
-                {
-                    _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]);
-                    displayPreferences.CustomPrefs.Remove(key);
-                }
+                _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]);
+                displayPreferences.CustomPrefs.Remove(key);
             }
+        }
 
-            var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client);
-            itemPrefs.SortBy = displayPreferences.SortBy ?? "SortName";
-            itemPrefs.SortOrder = displayPreferences.SortOrder;
-            itemPrefs.RememberIndexing = displayPreferences.RememberIndexing;
-            itemPrefs.RememberSorting = displayPreferences.RememberSorting;
-            itemPrefs.ItemId = itemId;
+        var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client);
+        itemPrefs.SortBy = displayPreferences.SortBy ?? "SortName";
+        itemPrefs.SortOrder = displayPreferences.SortOrder;
+        itemPrefs.RememberIndexing = displayPreferences.RememberIndexing;
+        itemPrefs.RememberSorting = displayPreferences.RememberSorting;
+        itemPrefs.ItemId = itemId;
 
-            // Set all remaining custom preferences.
-            _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
-            _displayPreferencesManager.SaveChanges();
+        // Set all remaining custom preferences.
+        _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
+        _displayPreferencesManager.SaveChanges();
 
-            return NoContent();
-        }
+        return NoContent();
     }
 }

+ 103 - 104
Jellyfin.Api/Controllers/DlnaController.cs

@@ -7,127 +7,126 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Dlna Controller.
+/// </summary>
+[Authorize(Policy = Policies.RequiresElevation)]
+public class DlnaController : BaseJellyfinApiController
 {
+    private readonly IDlnaManager _dlnaManager;
+
     /// <summary>
-    /// Dlna Controller.
+    /// Initializes a new instance of the <see cref="DlnaController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.RequiresElevation)]
-    public class DlnaController : BaseJellyfinApiController
+    /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+    public DlnaController(IDlnaManager dlnaManager)
     {
-        private readonly IDlnaManager _dlnaManager;
+        _dlnaManager = dlnaManager;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DlnaController"/> class.
-        /// </summary>
-        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-        public DlnaController(IDlnaManager dlnaManager)
-        {
-            _dlnaManager = dlnaManager;
-        }
+    /// <summary>
+    /// Get profile infos.
+    /// </summary>
+    /// <response code="200">Device profile infos returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the device profile infos.</returns>
+    [HttpGet("ProfileInfos")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<DeviceProfileInfo>> GetProfileInfos()
+    {
+        return Ok(_dlnaManager.GetProfileInfos());
+    }
 
-        /// <summary>
-        /// Get profile infos.
-        /// </summary>
-        /// <response code="200">Device profile infos returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the device profile infos.</returns>
-        [HttpGet("ProfileInfos")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<DeviceProfileInfo>> GetProfileInfos()
-        {
-            return Ok(_dlnaManager.GetProfileInfos());
-        }
+    /// <summary>
+    /// Gets the default profile.
+    /// </summary>
+    /// <response code="200">Default device profile returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the default profile.</returns>
+    [HttpGet("Profiles/Default")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<DeviceProfile> GetDefaultProfile()
+    {
+        return _dlnaManager.GetDefaultProfile();
+    }
 
-        /// <summary>
-        /// Gets the default profile.
-        /// </summary>
-        /// <response code="200">Default device profile returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the default profile.</returns>
-        [HttpGet("Profiles/Default")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<DeviceProfile> GetDefaultProfile()
+    /// <summary>
+    /// Gets a single profile.
+    /// </summary>
+    /// <param name="profileId">Profile Id.</param>
+    /// <response code="200">Device profile returned.</response>
+    /// <response code="404">Device profile not found.</response>
+    /// <returns>An <see cref="OkResult"/> containing the profile on success, or a <see cref="NotFoundResult"/> if device profile not found.</returns>
+    [HttpGet("Profiles/{profileId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<DeviceProfile> GetProfile([FromRoute, Required] string profileId)
+    {
+        var profile = _dlnaManager.GetProfile(profileId);
+        if (profile is null)
         {
-            return _dlnaManager.GetDefaultProfile();
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets a single profile.
-        /// </summary>
-        /// <param name="profileId">Profile Id.</param>
-        /// <response code="200">Device profile returned.</response>
-        /// <response code="404">Device profile not found.</response>
-        /// <returns>An <see cref="OkResult"/> containing the profile on success, or a <see cref="NotFoundResult"/> if device profile not found.</returns>
-        [HttpGet("Profiles/{profileId}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<DeviceProfile> GetProfile([FromRoute, Required] string profileId)
-        {
-            var profile = _dlnaManager.GetProfile(profileId);
-            if (profile is null)
-            {
-                return NotFound();
-            }
+        return profile;
+    }
 
-            return profile;
+    /// <summary>
+    /// Deletes a profile.
+    /// </summary>
+    /// <param name="profileId">Profile id.</param>
+    /// <response code="204">Device profile deleted.</response>
+    /// <response code="404">Device profile not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns>
+    [HttpDelete("Profiles/{profileId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult DeleteProfile([FromRoute, Required] string profileId)
+    {
+        var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
+        if (existingDeviceProfile is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Deletes a profile.
-        /// </summary>
-        /// <param name="profileId">Profile id.</param>
-        /// <response code="204">Device profile deleted.</response>
-        /// <response code="404">Device profile not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns>
-        [HttpDelete("Profiles/{profileId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult DeleteProfile([FromRoute, Required] string profileId)
-        {
-            var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
-            if (existingDeviceProfile is null)
-            {
-                return NotFound();
-            }
+        _dlnaManager.DeleteProfile(profileId);
+        return NoContent();
+    }
 
-            _dlnaManager.DeleteProfile(profileId);
-            return NoContent();
-        }
+    /// <summary>
+    /// Creates a profile.
+    /// </summary>
+    /// <param name="deviceProfile">Device profile.</param>
+    /// <response code="204">Device profile created.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Profiles")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile)
+    {
+        _dlnaManager.CreateProfile(deviceProfile);
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Creates a profile.
-        /// </summary>
-        /// <param name="deviceProfile">Device profile.</param>
-        /// <response code="204">Device profile created.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Profiles")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile)
+    /// <summary>
+    /// Updates a profile.
+    /// </summary>
+    /// <param name="profileId">Profile id.</param>
+    /// <param name="deviceProfile">Device profile.</param>
+    /// <response code="204">Device profile updated.</response>
+    /// <response code="404">Device profile not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns>
+    [HttpPost("Profiles/{profileId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult UpdateProfile([FromRoute, Required] string profileId, [FromBody] DeviceProfile deviceProfile)
+    {
+        var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
+        if (existingDeviceProfile is null)
         {
-            _dlnaManager.CreateProfile(deviceProfile);
-            return NoContent();
+            return NotFound();
         }
 
-        /// <summary>
-        /// Updates a profile.
-        /// </summary>
-        /// <param name="profileId">Profile id.</param>
-        /// <param name="deviceProfile">Device profile.</param>
-        /// <response code="204">Device profile updated.</response>
-        /// <response code="404">Device profile not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns>
-        [HttpPost("Profiles/{profileId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult UpdateProfile([FromRoute, Required] string profileId, [FromBody] DeviceProfile deviceProfile)
-        {
-            var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
-            if (existingDeviceProfile is null)
-            {
-                return NotFound();
-            }
-
-            _dlnaManager.UpdateProfile(profileId, deviceProfile);
-            return NoContent();
-        }
+        _dlnaManager.UpdateProfile(profileId, deviceProfile);
+        return NoContent();
     }
 }

+ 275 - 276
Jellyfin.Api/Controllers/DlnaServerController.cs

@@ -14,311 +14,310 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Dlna Server Controller.
+/// </summary>
+[Route("Dlna")]
+[DlnaEnabled]
+[Authorize(Policy = Policies.AnonymousLanAccessPolicy)]
+public class DlnaServerController : BaseJellyfinApiController
 {
+    private readonly IDlnaManager _dlnaManager;
+    private readonly IContentDirectory _contentDirectory;
+    private readonly IConnectionManager _connectionManager;
+    private readonly IMediaReceiverRegistrar _mediaReceiverRegistrar;
+
     /// <summary>
-    /// Dlna Server Controller.
+    /// Initializes a new instance of the <see cref="DlnaServerController"/> class.
     /// </summary>
-    [Route("Dlna")]
-    [DlnaEnabled]
-    [Authorize(Policy = Policies.AnonymousLanAccessPolicy)]
-    public class DlnaServerController : BaseJellyfinApiController
+    /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+    public DlnaServerController(IDlnaManager dlnaManager)
     {
-        private readonly IDlnaManager _dlnaManager;
-        private readonly IContentDirectory _contentDirectory;
-        private readonly IConnectionManager _connectionManager;
-        private readonly IMediaReceiverRegistrar _mediaReceiverRegistrar;
+        _dlnaManager = dlnaManager;
+        _contentDirectory = DlnaEntryPoint.Current.ContentDirectory;
+        _connectionManager = DlnaEntryPoint.Current.ConnectionManager;
+        _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DlnaServerController"/> class.
-        /// </summary>
-        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-        public DlnaServerController(IDlnaManager dlnaManager)
-        {
-            _dlnaManager = dlnaManager;
-            _contentDirectory = DlnaEntryPoint.Current.ContentDirectory;
-            _connectionManager = DlnaEntryPoint.Current.ConnectionManager;
-            _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar;
-        }
+    /// <summary>
+    /// Get Description Xml.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Description xml returned.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>An <see cref="OkResult"/> containing the description xml.</returns>
+    [HttpGet("{serverId}/description")]
+    [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public ActionResult<string> GetDescriptionXml([FromRoute, Required] string serverId)
+    {
+        var url = GetAbsoluteUri();
+        var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase));
+        var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress);
+        return Ok(xml);
+    }
 
-        /// <summary>
-        /// Get Description Xml.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Description xml returned.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>An <see cref="OkResult"/> containing the description xml.</returns>
-        [HttpGet("{serverId}/description")]
-        [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public ActionResult<string> GetDescriptionXml([FromRoute, Required] string serverId)
-        {
-            var url = GetAbsoluteUri();
-            var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase));
-            var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress);
-            return Ok(xml);
-        }
+    /// <summary>
+    /// Gets Dlna content directory xml.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Dlna content directory returned.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns>
+    [HttpGet("{serverId}/ContentDirectory")]
+    [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")]
+    [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    public ActionResult<string> GetContentDirectory([FromRoute, Required] string serverId)
+    {
+        return Ok(_contentDirectory.GetServiceXml());
+    }
 
-        /// <summary>
-        /// Gets Dlna content directory xml.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Dlna content directory returned.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns>
-        [HttpGet("{serverId}/ContentDirectory")]
-        [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")]
-        [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        public ActionResult<string> GetContentDirectory([FromRoute, Required] string serverId)
-        {
-            return Ok(_contentDirectory.GetServiceXml());
-        }
+    /// <summary>
+    /// Gets Dlna media receiver registrar xml.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Dlna media receiver registrar xml returned.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Dlna media receiver registrar xml.</returns>
+    [HttpGet("{serverId}/MediaReceiverRegistrar")]
+    [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
+    [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    public ActionResult<string> GetMediaReceiverRegistrar([FromRoute, Required] string serverId)
+    {
+        return Ok(_mediaReceiverRegistrar.GetServiceXml());
+    }
 
-        /// <summary>
-        /// Gets Dlna media receiver registrar xml.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Dlna media receiver registrar xml returned.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Dlna media receiver registrar xml.</returns>
-        [HttpGet("{serverId}/MediaReceiverRegistrar")]
-        [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
-        [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        public ActionResult<string> GetMediaReceiverRegistrar([FromRoute, Required] string serverId)
-        {
-            return Ok(_mediaReceiverRegistrar.GetServiceXml());
-        }
+    /// <summary>
+    /// Gets Dlna media receiver registrar xml.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Dlna media receiver registrar xml returned.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Dlna media receiver registrar xml.</returns>
+    [HttpGet("{serverId}/ConnectionManager")]
+    [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
+    [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    public ActionResult<string> GetConnectionManager([FromRoute, Required] string serverId)
+    {
+        return Ok(_connectionManager.GetServiceXml());
+    }
 
-        /// <summary>
-        /// Gets Dlna media receiver registrar xml.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Dlna media receiver registrar xml returned.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Dlna media receiver registrar xml.</returns>
-        [HttpGet("{serverId}/ConnectionManager")]
-        [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
-        [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        public ActionResult<string> GetConnectionManager([FromRoute, Required] string serverId)
-        {
-            return Ok(_connectionManager.GetServiceXml());
-        }
+    /// <summary>
+    /// Process a content directory control request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Control response.</returns>
+    [HttpPost("{serverId}/ContentDirectory/Control")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId)
+    {
+        return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Process a content directory control request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Control response.</returns>
-        [HttpPost("{serverId}/ContentDirectory/Control")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId)
-        {
-            return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Process a connection manager control request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Control response.</returns>
+    [HttpPost("{serverId}/ConnectionManager/Control")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId)
+    {
+        return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Process a connection manager control request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Control response.</returns>
-        [HttpPost("{serverId}/ConnectionManager/Control")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId)
-        {
-            return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Process a media receiver registrar control request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Control response.</returns>
+    [HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId)
+    {
+        return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Process a media receiver registrar control request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Control response.</returns>
-        [HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId)
-        {
-            return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Processes an event subscription request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Event subscription response.</returns>
+    [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
+    [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
+    [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
+    {
+        return ProcessEventRequest(_mediaReceiverRegistrar);
+    }
 
-        /// <summary>
-        /// Processes an event subscription request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Event subscription response.</returns>
-        [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
-        [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
-        [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
-        {
-            return ProcessEventRequest(_mediaReceiverRegistrar);
-        }
+    /// <summary>
+    /// Processes an event subscription request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Event subscription response.</returns>
+    [HttpSubscribe("{serverId}/ContentDirectory/Events")]
+    [HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
+    [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
+    {
+        return ProcessEventRequest(_contentDirectory);
+    }
 
-        /// <summary>
-        /// Processes an event subscription request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Event subscription response.</returns>
-        [HttpSubscribe("{serverId}/ContentDirectory/Events")]
-        [HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
-        [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
-        {
-            return ProcessEventRequest(_contentDirectory);
-        }
+    /// <summary>
+    /// Processes an event subscription request.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Event subscription response.</returns>
+    [HttpSubscribe("{serverId}/ConnectionManager/Events")]
+    [HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
+    [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [Produces(MediaTypeNames.Text.Xml)]
+    [ProducesFile(MediaTypeNames.Text.Xml)]
+    public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
+    {
+        return ProcessEventRequest(_connectionManager);
+    }
 
-        /// <summary>
-        /// Processes an event subscription request.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Event subscription response.</returns>
-        [HttpSubscribe("{serverId}/ConnectionManager/Events")]
-        [HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
-        [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [Produces(MediaTypeNames.Text.Xml)]
-        [ProducesFile(MediaTypeNames.Text.Xml)]
-        public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
-        {
-            return ProcessEventRequest(_connectionManager);
-        }
+    /// <summary>
+    /// Gets a server icon.
+    /// </summary>
+    /// <param name="serverId">Server UUID.</param>
+    /// <param name="fileName">The icon filename.</param>
+    /// <response code="200">Request processed.</response>
+    /// <response code="404">Not Found.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    /// <returns>Icon stream.</returns>
+    [HttpGet("{serverId}/icons/{fileName}")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [ProducesImageFile]
+    public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName)
+    {
+        return GetIconInternal(fileName);
+    }
 
-        /// <summary>
-        /// Gets a server icon.
-        /// </summary>
-        /// <param name="serverId">Server UUID.</param>
-        /// <param name="fileName">The icon filename.</param>
-        /// <response code="200">Request processed.</response>
-        /// <response code="404">Not Found.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        /// <returns>Icon stream.</returns>
-        [HttpGet("{serverId}/icons/{fileName}")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [ProducesImageFile]
-        public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName)
-        {
-            return GetIconInternal(fileName);
-        }
+    /// <summary>
+    /// Gets a server icon.
+    /// </summary>
+    /// <param name="fileName">The icon filename.</param>
+    /// <returns>Icon stream.</returns>
+    /// <response code="200">Request processed.</response>
+    /// <response code="404">Not Found.</response>
+    /// <response code="503">DLNA is disabled.</response>
+    [HttpGet("icons/{fileName}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+    [ProducesImageFile]
+    public ActionResult GetIcon([FromRoute, Required] string fileName)
+    {
+        return GetIconInternal(fileName);
+    }
 
-        /// <summary>
-        /// Gets a server icon.
-        /// </summary>
-        /// <param name="fileName">The icon filename.</param>
-        /// <returns>Icon stream.</returns>
-        /// <response code="200">Request processed.</response>
-        /// <response code="404">Not Found.</response>
-        /// <response code="503">DLNA is disabled.</response>
-        [HttpGet("icons/{fileName}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
-        [ProducesImageFile]
-        public ActionResult GetIcon([FromRoute, Required] string fileName)
+    private ActionResult GetIconInternal(string fileName)
+    {
+        var icon = _dlnaManager.GetIcon(fileName);
+        if (icon is null)
         {
-            return GetIconInternal(fileName);
+            return NotFound();
         }
 
-        private ActionResult GetIconInternal(string fileName)
-        {
-            var icon = _dlnaManager.GetIcon(fileName);
-            if (icon is null)
-            {
-                return NotFound();
-            }
+        return File(icon.Stream, MimeTypes.GetMimeType(fileName));
+    }
 
-            return File(icon.Stream, MimeTypes.GetMimeType(fileName));
-        }
+    private string GetAbsoluteUri()
+    {
+        return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}";
+    }
 
-        private string GetAbsoluteUri()
+    private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service)
+    {
+        return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers)
         {
-            return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}";
-        }
+            InputXml = requestStream,
+            TargetServerUuId = id,
+            RequestedUrl = GetAbsoluteUri()
+        });
+    }
 
-        private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service)
+    private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager)
+    {
+        var subscriptionId = Request.Headers["SID"];
+        if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase))
         {
-            return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers)
-            {
-                InputXml = requestStream,
-                TargetServerUuId = id,
-                RequestedUrl = GetAbsoluteUri()
-            });
-        }
+            var notificationType = Request.Headers["NT"];
+            var callback = Request.Headers["CALLBACK"];
+            var timeoutString = Request.Headers["TIMEOUT"];
 
-        private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager)
-        {
-            var subscriptionId = Request.Headers["SID"];
-            if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase))
+            if (string.IsNullOrEmpty(notificationType))
             {
-                var notificationType = Request.Headers["NT"];
-                var callback = Request.Headers["CALLBACK"];
-                var timeoutString = Request.Headers["TIMEOUT"];
-
-                if (string.IsNullOrEmpty(notificationType))
-                {
-                    return dlnaEventManager.RenewEventSubscription(
-                        subscriptionId,
-                        notificationType,
-                        timeoutString,
-                        callback);
-                }
-
-                return dlnaEventManager.CreateEventSubscription(notificationType, timeoutString, callback);
+                return dlnaEventManager.RenewEventSubscription(
+                    subscriptionId,
+                    notificationType,
+                    timeoutString,
+                    callback);
             }
 
-            return dlnaEventManager.CancelEventSubscription(subscriptionId);
+            return dlnaEventManager.CreateEventSubscription(notificationType, timeoutString, callback);
         }
+
+        return dlnaEventManager.CancelEventSubscription(subscriptionId);
     }
 }

+ 1829 - 1831
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -9,7 +9,6 @@ using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Models.PlaybackDtos;
 using Jellyfin.Api.Models.StreamingDtos;
@@ -30,2026 +29,2025 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Dynamic hls controller.
+/// </summary>
+[Route("")]
+[Authorize]
+public class DynamicHlsController : BaseJellyfinApiController
 {
+    private const string DefaultVodEncoderPreset = "veryfast";
+    private const string DefaultEventEncoderPreset = "superfast";
+    private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
+
+    private readonly ILibraryManager _libraryManager;
+    private readonly IUserManager _userManager;
+    private readonly IDlnaManager _dlnaManager;
+    private readonly IMediaSourceManager _mediaSourceManager;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly IMediaEncoder _mediaEncoder;
+    private readonly IFileSystem _fileSystem;
+    private readonly IDeviceManager _deviceManager;
+    private readonly TranscodingJobHelper _transcodingJobHelper;
+    private readonly ILogger<DynamicHlsController> _logger;
+    private readonly EncodingHelper _encodingHelper;
+    private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
+    private readonly DynamicHlsHelper _dynamicHlsHelper;
+    private readonly EncodingOptions _encodingOptions;
+
     /// <summary>
-    /// Dynamic hls controller.
+    /// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class DynamicHlsController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
+    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+    /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param>
+    /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
+    /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
+    /// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param>
+    public DynamicHlsController(
+        ILibraryManager libraryManager,
+        IUserManager userManager,
+        IDlnaManager dlnaManager,
+        IMediaSourceManager mediaSourceManager,
+        IServerConfigurationManager serverConfigurationManager,
+        IMediaEncoder mediaEncoder,
+        IFileSystem fileSystem,
+        IDeviceManager deviceManager,
+        TranscodingJobHelper transcodingJobHelper,
+        ILogger<DynamicHlsController> logger,
+        DynamicHlsHelper dynamicHlsHelper,
+        EncodingHelper encodingHelper,
+        IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
     {
-        private const string DefaultVodEncoderPreset = "veryfast";
-        private const string DefaultEventEncoderPreset = "superfast";
-        private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
-
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-        private readonly IDlnaManager _dlnaManager;
-        private readonly IMediaSourceManager _mediaSourceManager;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly IMediaEncoder _mediaEncoder;
-        private readonly IFileSystem _fileSystem;
-        private readonly IDeviceManager _deviceManager;
-        private readonly TranscodingJobHelper _transcodingJobHelper;
-        private readonly ILogger<DynamicHlsController> _logger;
-        private readonly EncodingHelper _encodingHelper;
-        private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
-        private readonly DynamicHlsHelper _dynamicHlsHelper;
-        private readonly EncodingOptions _encodingOptions;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
-        /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param>
-        /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
-        /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
-        /// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param>
-        public DynamicHlsController(
-            ILibraryManager libraryManager,
-            IUserManager userManager,
-            IDlnaManager dlnaManager,
-            IMediaSourceManager mediaSourceManager,
-            IServerConfigurationManager serverConfigurationManager,
-            IMediaEncoder mediaEncoder,
-            IFileSystem fileSystem,
-            IDeviceManager deviceManager,
-            TranscodingJobHelper transcodingJobHelper,
-            ILogger<DynamicHlsController> logger,
-            DynamicHlsHelper dynamicHlsHelper,
-            EncodingHelper encodingHelper,
-            IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
-        {
-            _libraryManager = libraryManager;
-            _userManager = userManager;
-            _dlnaManager = dlnaManager;
-            _mediaSourceManager = mediaSourceManager;
-            _serverConfigurationManager = serverConfigurationManager;
-            _mediaEncoder = mediaEncoder;
-            _fileSystem = fileSystem;
-            _deviceManager = deviceManager;
-            _transcodingJobHelper = transcodingJobHelper;
-            _logger = logger;
-            _dynamicHlsHelper = dynamicHlsHelper;
-            _encodingHelper = encodingHelper;
-            _dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
-
-            _encodingOptions = serverConfigurationManager.GetEncodingOptions();
-        }
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+        _dlnaManager = dlnaManager;
+        _mediaSourceManager = mediaSourceManager;
+        _serverConfigurationManager = serverConfigurationManager;
+        _mediaEncoder = mediaEncoder;
+        _fileSystem = fileSystem;
+        _deviceManager = deviceManager;
+        _transcodingJobHelper = transcodingJobHelper;
+        _logger = logger;
+        _dynamicHlsHelper = dynamicHlsHelper;
+        _encodingHelper = encodingHelper;
+        _dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
+
+        _encodingOptions = serverConfigurationManager.GetEncodingOptions();
+    }
 
-        /// <summary>
-        /// Gets a hls live stream.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="container">The audio container.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <param name="maxWidth">Optional. The max width.</param>
-        /// <param name="maxHeight">Optional. The max height.</param>
-        /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param>
-        /// <response code="200">Hls live stream retrieved.</response>
-        /// <returns>A <see cref="FileResult"/> containing the hls file.</returns>
-        [HttpGet("Videos/{itemId}/live.m3u8")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        public async Task<ActionResult> GetLiveHlsStream(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] string? container,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] bool? enableSubtitlesInManifest)
+    /// <summary>
+    /// Gets a hls live stream.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="container">The audio container.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <param name="maxWidth">Optional. The max width.</param>
+    /// <param name="maxHeight">Optional. The max height.</param>
+    /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param>
+    /// <response code="200">Hls live stream retrieved.</response>
+    /// <returns>A <see cref="FileResult"/> containing the hls file.</returns>
+    [HttpGet("Videos/{itemId}/live.m3u8")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetLiveHlsStream(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] string? container,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] bool? enableSubtitlesInManifest)
+    {
+        VideoRequestDto streamingRequest = new VideoRequestDto
         {
-            VideoRequestDto streamingRequest = new VideoRequestDto
-            {
-                Id = itemId,
-                Container = container,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions,
-                MaxHeight = maxHeight,
-                MaxWidth = maxWidth,
-                EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true
-            };
-
-            // CTS lifecycle is managed internally.
-            var cancellationTokenSource = new CancellationTokenSource();
-            // Due to CTS.Token calling ThrowIfDisposed (https://github.com/dotnet/runtime/issues/29970) we have to "cache" the token
-            // since it gets disposed when ffmpeg exits
-            var cancellationToken = cancellationTokenSource.Token;
-            var state = await StreamingHelpers.GetStreamingState(
-                    streamingRequest,
-                    HttpContext,
-                    _mediaSourceManager,
-                    _userManager,
-                    _libraryManager,
-                    _serverConfigurationManager,
-                    _mediaEncoder,
-                    _encodingHelper,
-                    _dlnaManager,
-                    _deviceManager,
-                    _transcodingJobHelper,
-                    TranscodingJobType,
-                    cancellationToken)
-                .ConfigureAwait(false);
-
-            TranscodingJobDto? job = null;
-            var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
-
-            if (!System.IO.File.Exists(playlistPath))
+            Id = itemId,
+            Container = container,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions,
+            MaxHeight = maxHeight,
+            MaxWidth = maxWidth,
+            EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true
+        };
+
+        // CTS lifecycle is managed internally.
+        var cancellationTokenSource = new CancellationTokenSource();
+        // Due to CTS.Token calling ThrowIfDisposed (https://github.com/dotnet/runtime/issues/29970) we have to "cache" the token
+        // since it gets disposed when ffmpeg exits
+        var cancellationToken = cancellationTokenSource.Token;
+        var state = await StreamingHelpers.GetStreamingState(
+                streamingRequest,
+                HttpContext,
+                _mediaSourceManager,
+                _userManager,
+                _libraryManager,
+                _serverConfigurationManager,
+                _mediaEncoder,
+                _encodingHelper,
+                _dlnaManager,
+                _deviceManager,
+                _transcodingJobHelper,
+                TranscodingJobType,
+                cancellationToken)
+            .ConfigureAwait(false);
+
+        TranscodingJobDto? job = null;
+        var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
+
+        if (!System.IO.File.Exists(playlistPath))
+        {
+            var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
+            await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+            try
             {
-                var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
-                await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false);
-                try
+                if (!System.IO.File.Exists(playlistPath))
                 {
-                    if (!System.IO.File.Exists(playlistPath))
+                    // If the playlist doesn't already exist, startup ffmpeg
+                    try
                     {
-                        // If the playlist doesn't already exist, startup ffmpeg
-                        try
-                        {
-                            job = await _transcodingJobHelper.StartFfMpeg(
-                                    state,
-                                    playlistPath,
-                                    GetCommandLineArguments(playlistPath, state, true, 0),
-                                    Request,
-                                    TranscodingJobType,
-                                    cancellationTokenSource)
-                                .ConfigureAwait(false);
-                            job.IsLiveOutput = true;
-                        }
-                        catch
-                        {
-                            state.Dispose();
-                            throw;
-                        }
+                        job = await _transcodingJobHelper.StartFfMpeg(
+                                state,
+                                playlistPath,
+                                GetCommandLineArguments(playlistPath, state, true, 0),
+                                Request,
+                                TranscodingJobType,
+                                cancellationTokenSource)
+                            .ConfigureAwait(false);
+                        job.IsLiveOutput = true;
+                    }
+                    catch
+                    {
+                        state.Dispose();
+                        throw;
+                    }
 
-                        minSegments = state.MinSegments;
-                        if (minSegments > 0)
-                        {
-                            await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationToken).ConfigureAwait(false);
-                        }
+                    minSegments = state.MinSegments;
+                    if (minSegments > 0)
+                    {
+                        await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationToken).ConfigureAwait(false);
                     }
                 }
-                finally
-                {
-                    transcodingLock.Release();
-                }
             }
-
-            job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
-
-            if (job is not null)
+            finally
             {
-                _transcodingJobHelper.OnTranscodeEndRequest(job);
+                transcodingLock.Release();
             }
-
-            var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state);
-
-            return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8"));
         }
 
-        /// <summary>
-        /// Gets a video hls playlist stream.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
-        /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
-        /// <response code="200">Video stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
-        [HttpGet("Videos/{itemId}/master.m3u8")]
-        [HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadMasterHlsVideoPlaylist")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        public async Task<ActionResult> GetMasterHlsVideoPlaylist(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery, Required] string mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions,
-            [FromQuery] bool enableAdaptiveBitrateStreaming = true)
-        {
-            var streamingRequest = new HlsVideoRequestDto
-            {
-                Id = itemId,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                MaxWidth = maxWidth,
-                MaxHeight = maxHeight,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions,
-                EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
-            };
+        job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
 
-            return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+        if (job is not null)
+        {
+            _transcodingJobHelper.OnTranscodeEndRequest(job);
         }
 
-        /// <summary>
-        /// Gets an audio hls playlist stream.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
-        /// <response code="200">Audio stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
-        [HttpGet("Audio/{itemId}/master.m3u8")]
-        [HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadMasterHlsAudioPlaylist")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        public async Task<ActionResult> GetMasterHlsAudioPlaylist(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery, Required] string mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? maxStreamingBitrate,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions,
-            [FromQuery] bool enableAdaptiveBitrateStreaming = true)
-        {
-            var streamingRequest = new HlsAudioRequestDto
-            {
-                Id = itemId,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions,
-                EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
-            };
+        var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state);
 
-            return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
-        }
+        return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8"));
+    }
 
-        /// <summary>
-        /// Gets a video stream using HTTP live streaming.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
-        /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Video stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("Videos/{itemId}/main.m3u8")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        public async Task<ActionResult> GetVariantHlsVideoPlaylist(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions)
+    /// <summary>
+    /// Gets a video hls playlist stream.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
+    /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
+    /// <response code="200">Video stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
+    [HttpGet("Videos/{itemId}/master.m3u8")]
+    [HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadMasterHlsVideoPlaylist")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetMasterHlsVideoPlaylist(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery, Required] string mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions,
+        [FromQuery] bool enableAdaptiveBitrateStreaming = true)
+    {
+        var streamingRequest = new HlsVideoRequestDto
         {
-            using var cancellationTokenSource = new CancellationTokenSource();
-            var streamingRequest = new VideoRequestDto
-            {
-                Id = itemId,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                MaxWidth = maxWidth,
-                MaxHeight = maxHeight,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions
-            };
-
-            return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
-                .ConfigureAwait(false);
-        }
+            Id = itemId,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            MaxWidth = maxWidth,
+            MaxHeight = maxHeight,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions,
+            EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
+        };
+
+        return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Gets an audio stream using HTTP live streaming.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Audio stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("Audio/{itemId}/main.m3u8")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        public async Task<ActionResult> GetVariantHlsAudioPlaylist(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? maxStreamingBitrate,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions)
+    /// <summary>
+    /// Gets an audio hls playlist stream.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
+    /// <response code="200">Audio stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
+    [HttpGet("Audio/{itemId}/master.m3u8")]
+    [HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadMasterHlsAudioPlaylist")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetMasterHlsAudioPlaylist(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery, Required] string mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? maxStreamingBitrate,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions,
+        [FromQuery] bool enableAdaptiveBitrateStreaming = true)
+    {
+        var streamingRequest = new HlsAudioRequestDto
         {
-            using var cancellationTokenSource = new CancellationTokenSource();
-            var streamingRequest = new StreamingRequestDto
-            {
-                Id = itemId,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions
-            };
+            Id = itemId,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate ?? maxStreamingBitrate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions,
+            EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
+        };
+
+        return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+    }
 
-            return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
-                .ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Gets a video stream using HTTP live streaming.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
+    /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Video stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("Videos/{itemId}/main.m3u8")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetVariantHlsVideoPlaylist(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions)
+    {
+        using var cancellationTokenSource = new CancellationTokenSource();
+        var streamingRequest = new VideoRequestDto
+        {
+            Id = itemId,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            MaxWidth = maxWidth,
+            MaxHeight = maxHeight,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions
+        };
+
+        return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
+            .ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Gets a video stream using HTTP live streaming.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <param name="segmentId">The segment id.</param>
-        /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
-        /// <param name="runtimeTicks">The position of the requested segment in ticks.</param>
-        /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The desired segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
-        /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Video stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesVideoFile]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> GetHlsVideoSegment(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] string playlistId,
-            [FromRoute, Required] int segmentId,
-            [FromRoute, Required] string container,
-            [FromQuery, Required] long runtimeTicks,
-            [FromQuery, Required] long actualSegmentLengthTicks,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions)
+    /// <summary>
+    /// Gets an audio stream using HTTP live streaming.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Audio stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("Audio/{itemId}/main.m3u8")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetVariantHlsAudioPlaylist(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? maxStreamingBitrate,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions)
+    {
+        using var cancellationTokenSource = new CancellationTokenSource();
+        var streamingRequest = new StreamingRequestDto
         {
-            var streamingRequest = new VideoRequestDto
-            {
-                Id = itemId,
-                CurrentRuntimeTicks = runtimeTicks,
-                ActualSegmentLengthTicks = actualSegmentLengthTicks,
-                Container = container,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                MaxWidth = maxWidth,
-                MaxHeight = maxHeight,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions
-            };
+            Id = itemId,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate ?? maxStreamingBitrate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions
+        };
+
+        return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
+            .ConfigureAwait(false);
+    }
 
-            return await GetDynamicSegment(streamingRequest, segmentId)
-                .ConfigureAwait(false);
-        }
+    /// <summary>
+    /// Gets a video stream using HTTP live streaming.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="segmentId">The segment id.</param>
+    /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
+    /// <param name="runtimeTicks">The position of the requested segment in ticks.</param>
+    /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The desired segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
+    /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Video stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesVideoFile]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> GetHlsVideoSegment(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] string playlistId,
+        [FromRoute, Required] int segmentId,
+        [FromRoute, Required] string container,
+        [FromQuery, Required] long runtimeTicks,
+        [FromQuery, Required] long actualSegmentLengthTicks,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions)
+    {
+        var streamingRequest = new VideoRequestDto
+        {
+            Id = itemId,
+            CurrentRuntimeTicks = runtimeTicks,
+            ActualSegmentLengthTicks = actualSegmentLengthTicks,
+            Container = container,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            MaxWidth = maxWidth,
+            MaxHeight = maxHeight,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions
+        };
+
+        return await GetDynamicSegment(streamingRequest, segmentId)
+            .ConfigureAwait(false);
+    }
 
-        /// <summary>
-        /// Gets a video stream using HTTP live streaming.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <param name="segmentId">The segment id.</param>
-        /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
-        /// <param name="runtimeTicks">The position of the requested segment in ticks.</param>
-        /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param>
-        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
-        /// <param name="params">The streaming parameters.</param>
-        /// <param name="tag">The tag.</param>
-        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <param name="segmentLength">The segment length.</param>
-        /// <param name="minSegments">The minimum number of segments.</param>
-        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
-        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
-        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
-        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
-        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
-        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
-        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
-        /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
-        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
-        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
-        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
-        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
-        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
-        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
-        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
-        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
-        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
-        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
-        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
-        /// <param name="maxRefFrames">Optional.</param>
-        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
-        /// <param name="requireAvc">Optional. Whether to require avc.</param>
-        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
-        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
-        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
-        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
-        /// <param name="liveStreamId">The live stream id.</param>
-        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
-        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
-        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
-        /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
-        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
-        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
-        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
-        /// <param name="streamOptions">Optional. The streaming options.</param>
-        /// <response code="200">Video stream returned.</response>
-        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesAudioFile]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> GetHlsAudioSegment(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] string playlistId,
-            [FromRoute, Required] int segmentId,
-            [FromRoute, Required] string container,
-            [FromQuery, Required] long runtimeTicks,
-            [FromQuery, Required] long actualSegmentLengthTicks,
-            [FromQuery] bool? @static,
-            [FromQuery] string? @params,
-            [FromQuery] string? tag,
-            [FromQuery] string? deviceProfileId,
-            [FromQuery] string? playSessionId,
-            [FromQuery] string? segmentContainer,
-            [FromQuery] int? segmentLength,
-            [FromQuery] int? minSegments,
-            [FromQuery] string? mediaSourceId,
-            [FromQuery] string? deviceId,
-            [FromQuery] string? audioCodec,
-            [FromQuery] bool? enableAutoStreamCopy,
-            [FromQuery] bool? allowVideoStreamCopy,
-            [FromQuery] bool? allowAudioStreamCopy,
-            [FromQuery] bool? breakOnNonKeyFrames,
-            [FromQuery] int? audioSampleRate,
-            [FromQuery] int? maxAudioBitDepth,
-            [FromQuery] int? maxStreamingBitrate,
-            [FromQuery] int? audioBitRate,
-            [FromQuery] int? audioChannels,
-            [FromQuery] int? maxAudioChannels,
-            [FromQuery] string? profile,
-            [FromQuery] string? level,
-            [FromQuery] float? framerate,
-            [FromQuery] float? maxFramerate,
-            [FromQuery] bool? copyTimestamps,
-            [FromQuery] long? startTimeTicks,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? videoBitRate,
-            [FromQuery] int? subtitleStreamIndex,
-            [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
-            [FromQuery] int? maxRefFrames,
-            [FromQuery] int? maxVideoBitDepth,
-            [FromQuery] bool? requireAvc,
-            [FromQuery] bool? deInterlace,
-            [FromQuery] bool? requireNonAnamorphic,
-            [FromQuery] int? transcodingMaxAudioChannels,
-            [FromQuery] int? cpuCoreLimit,
-            [FromQuery] string? liveStreamId,
-            [FromQuery] bool? enableMpegtsM2TsMode,
-            [FromQuery] string? videoCodec,
-            [FromQuery] string? subtitleCodec,
-            [FromQuery] string? transcodeReasons,
-            [FromQuery] int? audioStreamIndex,
-            [FromQuery] int? videoStreamIndex,
-            [FromQuery] EncodingContext? context,
-            [FromQuery] Dictionary<string, string> streamOptions)
+    /// <summary>
+    /// Gets a video stream using HTTP live streaming.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="segmentId">The segment id.</param>
+    /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
+    /// <param name="runtimeTicks">The position of the requested segment in ticks.</param>
+    /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param>
+    /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+    /// <param name="params">The streaming parameters.</param>
+    /// <param name="tag">The tag.</param>
+    /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <param name="segmentLength">The segment length.</param>
+    /// <param name="minSegments">The minimum number of segments.</param>
+    /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+    /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+    /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+    /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+    /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+    /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+    /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+    /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
+    /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+    /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+    /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+    /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+    /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+    /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+    /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+    /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+    /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+    /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+    /// <param name="maxRefFrames">Optional.</param>
+    /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+    /// <param name="requireAvc">Optional. Whether to require avc.</param>
+    /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+    /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
+    /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+    /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+    /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
+    /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+    /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+    /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+    /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+    /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+    /// <param name="streamOptions">Optional. The streaming options.</param>
+    /// <response code="200">Video stream returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+    [HttpGet("Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesAudioFile]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> GetHlsAudioSegment(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] string playlistId,
+        [FromRoute, Required] int segmentId,
+        [FromRoute, Required] string container,
+        [FromQuery, Required] long runtimeTicks,
+        [FromQuery, Required] long actualSegmentLengthTicks,
+        [FromQuery] bool? @static,
+        [FromQuery] string? @params,
+        [FromQuery] string? tag,
+        [FromQuery] string? deviceProfileId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] string? segmentContainer,
+        [FromQuery] int? segmentLength,
+        [FromQuery] int? minSegments,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? deviceId,
+        [FromQuery] string? audioCodec,
+        [FromQuery] bool? enableAutoStreamCopy,
+        [FromQuery] bool? allowVideoStreamCopy,
+        [FromQuery] bool? allowAudioStreamCopy,
+        [FromQuery] bool? breakOnNonKeyFrames,
+        [FromQuery] int? audioSampleRate,
+        [FromQuery] int? maxAudioBitDepth,
+        [FromQuery] int? maxStreamingBitrate,
+        [FromQuery] int? audioBitRate,
+        [FromQuery] int? audioChannels,
+        [FromQuery] int? maxAudioChannels,
+        [FromQuery] string? profile,
+        [FromQuery] string? level,
+        [FromQuery] float? framerate,
+        [FromQuery] float? maxFramerate,
+        [FromQuery] bool? copyTimestamps,
+        [FromQuery] long? startTimeTicks,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? videoBitRate,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] SubtitleDeliveryMethod? subtitleMethod,
+        [FromQuery] int? maxRefFrames,
+        [FromQuery] int? maxVideoBitDepth,
+        [FromQuery] bool? requireAvc,
+        [FromQuery] bool? deInterlace,
+        [FromQuery] bool? requireNonAnamorphic,
+        [FromQuery] int? transcodingMaxAudioChannels,
+        [FromQuery] int? cpuCoreLimit,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] bool? enableMpegtsM2TsMode,
+        [FromQuery] string? videoCodec,
+        [FromQuery] string? subtitleCodec,
+        [FromQuery] string? transcodeReasons,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? videoStreamIndex,
+        [FromQuery] EncodingContext? context,
+        [FromQuery] Dictionary<string, string> streamOptions)
+    {
+        var streamingRequest = new StreamingRequestDto
         {
-            var streamingRequest = new StreamingRequestDto
-            {
-                Id = itemId,
-                Container = container,
-                CurrentRuntimeTicks = runtimeTicks,
-                ActualSegmentLengthTicks = actualSegmentLengthTicks,
-                Static = @static ?? false,
-                Params = @params,
-                Tag = tag,
-                DeviceProfileId = deviceProfileId,
-                PlaySessionId = playSessionId,
-                SegmentContainer = segmentContainer,
-                SegmentLength = segmentLength,
-                MinSegments = minSegments,
-                MediaSourceId = mediaSourceId,
-                DeviceId = deviceId,
-                AudioCodec = audioCodec,
-                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
-                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
-                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
-                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
-                AudioSampleRate = audioSampleRate,
-                MaxAudioChannels = maxAudioChannels,
-                AudioBitRate = audioBitRate ?? maxStreamingBitrate,
-                MaxAudioBitDepth = maxAudioBitDepth,
-                AudioChannels = audioChannels,
-                Profile = profile,
-                Level = level,
-                Framerate = framerate,
-                MaxFramerate = maxFramerate,
-                CopyTimestamps = copyTimestamps ?? false,
-                StartTimeTicks = startTimeTicks,
-                Width = width,
-                Height = height,
-                VideoBitRate = videoBitRate,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
-                MaxRefFrames = maxRefFrames,
-                MaxVideoBitDepth = maxVideoBitDepth,
-                RequireAvc = requireAvc ?? false,
-                DeInterlace = deInterlace ?? false,
-                RequireNonAnamorphic = requireNonAnamorphic ?? false,
-                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
-                CpuCoreLimit = cpuCoreLimit,
-                LiveStreamId = liveStreamId,
-                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
-                VideoCodec = videoCodec,
-                SubtitleCodec = subtitleCodec,
-                TranscodeReasons = transcodeReasons,
-                AudioStreamIndex = audioStreamIndex,
-                VideoStreamIndex = videoStreamIndex,
-                Context = context ?? EncodingContext.Streaming,
-                StreamOptions = streamOptions
-            };
+            Id = itemId,
+            Container = container,
+            CurrentRuntimeTicks = runtimeTicks,
+            ActualSegmentLengthTicks = actualSegmentLengthTicks,
+            Static = @static ?? false,
+            Params = @params,
+            Tag = tag,
+            DeviceProfileId = deviceProfileId,
+            PlaySessionId = playSessionId,
+            SegmentContainer = segmentContainer,
+            SegmentLength = segmentLength,
+            MinSegments = minSegments,
+            MediaSourceId = mediaSourceId,
+            DeviceId = deviceId,
+            AudioCodec = audioCodec,
+            EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+            AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+            AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+            BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+            AudioSampleRate = audioSampleRate,
+            MaxAudioChannels = maxAudioChannels,
+            AudioBitRate = audioBitRate ?? maxStreamingBitrate,
+            MaxAudioBitDepth = maxAudioBitDepth,
+            AudioChannels = audioChannels,
+            Profile = profile,
+            Level = level,
+            Framerate = framerate,
+            MaxFramerate = maxFramerate,
+            CopyTimestamps = copyTimestamps ?? false,
+            StartTimeTicks = startTimeTicks,
+            Width = width,
+            Height = height,
+            VideoBitRate = videoBitRate,
+            SubtitleStreamIndex = subtitleStreamIndex,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            MaxRefFrames = maxRefFrames,
+            MaxVideoBitDepth = maxVideoBitDepth,
+            RequireAvc = requireAvc ?? false,
+            DeInterlace = deInterlace ?? false,
+            RequireNonAnamorphic = requireNonAnamorphic ?? false,
+            TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+            CpuCoreLimit = cpuCoreLimit,
+            LiveStreamId = liveStreamId,
+            EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
+            VideoCodec = videoCodec,
+            SubtitleCodec = subtitleCodec,
+            TranscodeReasons = transcodeReasons,
+            AudioStreamIndex = audioStreamIndex,
+            VideoStreamIndex = videoStreamIndex,
+            Context = context ?? EncodingContext.Streaming,
+            StreamOptions = streamOptions
+        };
+
+        return await GetDynamicSegment(streamingRequest, segmentId)
+            .ConfigureAwait(false);
+    }
 
-            return await GetDynamicSegment(streamingRequest, segmentId)
-                .ConfigureAwait(false);
-        }
+    private async Task<ActionResult> GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, CancellationTokenSource cancellationTokenSource)
+    {
+        using var state = await StreamingHelpers.GetStreamingState(
+                streamingRequest,
+                HttpContext,
+                _mediaSourceManager,
+                _userManager,
+                _libraryManager,
+                _serverConfigurationManager,
+                _mediaEncoder,
+                _encodingHelper,
+                _dlnaManager,
+                _deviceManager,
+                _transcodingJobHelper,
+                TranscodingJobType,
+                cancellationTokenSource.Token)
+            .ConfigureAwait(false);
+
+        var request = new CreateMainPlaylistRequest(
+            state.MediaPath,
+            state.SegmentLength * 1000,
+            state.RunTimeTicks ?? 0,
+            state.Request.SegmentContainer ?? string.Empty,
+            "hls1/main/",
+            Request.QueryString.ToString(),
+            EncodingHelper.IsCopyCodec(state.OutputVideoCodec));
+        var playlist = _dynamicHlsPlaylistGenerator.CreateMainPlaylist(request);
+
+        return new FileContentResult(Encoding.UTF8.GetBytes(playlist), MimeTypes.GetMimeType("playlist.m3u8"));
+    }
 
-        private async Task<ActionResult> GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, CancellationTokenSource cancellationTokenSource)
+    private async Task<ActionResult> GetDynamicSegment(StreamingRequestDto streamingRequest, int segmentId)
+    {
+        if ((streamingRequest.StartTimeTicks ?? 0) > 0)
         {
-            using var state = await StreamingHelpers.GetStreamingState(
-                    streamingRequest,
-                    HttpContext,
-                    _mediaSourceManager,
-                    _userManager,
-                    _libraryManager,
-                    _serverConfigurationManager,
-                    _mediaEncoder,
-                    _encodingHelper,
-                    _dlnaManager,
-                    _deviceManager,
-                    _transcodingJobHelper,
-                    TranscodingJobType,
-                    cancellationTokenSource.Token)
-                .ConfigureAwait(false);
-
-            var request = new CreateMainPlaylistRequest(
-                state.MediaPath,
-                state.SegmentLength * 1000,
-                state.RunTimeTicks ?? 0,
-                state.Request.SegmentContainer ?? string.Empty,
-                "hls1/main/",
-                Request.QueryString.ToString(),
-                EncodingHelper.IsCopyCodec(state.OutputVideoCodec));
-            var playlist = _dynamicHlsPlaylistGenerator.CreateMainPlaylist(request);
-
-            return new FileContentResult(Encoding.UTF8.GetBytes(playlist), MimeTypes.GetMimeType("playlist.m3u8"));
+            throw new ArgumentException("StartTimeTicks is not allowed.");
         }
 
-        private async Task<ActionResult> GetDynamicSegment(StreamingRequestDto streamingRequest, int segmentId)
-        {
-            if ((streamingRequest.StartTimeTicks ?? 0) > 0)
-            {
-                throw new ArgumentException("StartTimeTicks is not allowed.");
-            }
+        // CTS lifecycle is managed internally.
+        var cancellationTokenSource = new CancellationTokenSource();
+        var cancellationToken = cancellationTokenSource.Token;
+
+        var state = await StreamingHelpers.GetStreamingState(
+                streamingRequest,
+                HttpContext,
+                _mediaSourceManager,
+                _userManager,
+                _libraryManager,
+                _serverConfigurationManager,
+                _mediaEncoder,
+                _encodingHelper,
+                _dlnaManager,
+                _deviceManager,
+                _transcodingJobHelper,
+                TranscodingJobType,
+                cancellationToken)
+            .ConfigureAwait(false);
 
-            // CTS lifecycle is managed internally.
-            var cancellationTokenSource = new CancellationTokenSource();
-            var cancellationToken = cancellationTokenSource.Token;
+        var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
 
-            var state = await StreamingHelpers.GetStreamingState(
-                    streamingRequest,
-                    HttpContext,
-                    _mediaSourceManager,
-                    _userManager,
-                    _libraryManager,
-                    _serverConfigurationManager,
-                    _mediaEncoder,
-                    _encodingHelper,
-                    _dlnaManager,
-                    _deviceManager,
-                    _transcodingJobHelper,
-                    TranscodingJobType,
-                    cancellationToken)
-                .ConfigureAwait(false);
+        var segmentPath = GetSegmentPath(state, playlistPath, segmentId);
 
-            var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
+        var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
 
-            var segmentPath = GetSegmentPath(state, playlistPath, segmentId);
+        TranscodingJobDto? job;
 
-            var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
+        if (System.IO.File.Exists(segmentPath))
+        {
+            job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
+            _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath);
+            return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
+        }
 
-            TranscodingJobDto? job;
+        var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
+        await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+        var released = false;
+        var startTranscoding = false;
 
+        try
+        {
             if (System.IO.File.Exists(segmentPath))
             {
                 job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
-                _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath);
+                transcodingLock.Release();
+                released = true;
+                _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath);
                 return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
             }
-
-            var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
-            await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false);
-            var released = false;
-            var startTranscoding = false;
-
-            try
+            else
             {
-                if (System.IO.File.Exists(segmentPath))
+                var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
+                var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength;
+
+                if (segmentId == -1)
                 {
-                    job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
-                    transcodingLock.Release();
-                    released = true;
-                    _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath);
-                    return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
+                    _logger.LogDebug("Starting transcoding because fmp4 init file is being requested");
+                    startTranscoding = true;
+                    segmentId = 0;
                 }
-                else
+                else if (currentTranscodingIndex is null)
                 {
-                    var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
-                    var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength;
-
-                    if (segmentId == -1)
-                    {
-                        _logger.LogDebug("Starting transcoding because fmp4 init file is being requested");
-                        startTranscoding = true;
-                        segmentId = 0;
-                    }
-                    else if (currentTranscodingIndex is null)
-                    {
-                        _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null");
-                        startTranscoding = true;
-                    }
-                    else if (segmentId < currentTranscodingIndex.Value)
-                    {
-                        _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex);
-                        startTranscoding = true;
-                    }
-                    else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange)
-                    {
-                        _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId);
-                        startTranscoding = true;
-                    }
+                    _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null");
+                    startTranscoding = true;
+                }
+                else if (segmentId < currentTranscodingIndex.Value)
+                {
+                    _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex);
+                    startTranscoding = true;
+                }
+                else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange)
+                {
+                    _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId);
+                    startTranscoding = true;
+                }
 
-                    if (startTranscoding)
+                if (startTranscoding)
+                {
+                    // If the playlist doesn't already exist, startup ffmpeg
+                    try
                     {
-                        // If the playlist doesn't already exist, startup ffmpeg
-                        try
-                        {
-                            await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false)
-                                .ConfigureAwait(false);
-
-                            if (currentTranscodingIndex.HasValue)
-                            {
-                                DeleteLastFile(playlistPath, segmentExtension, 0);
-                            }
-
-                            streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
+                        await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false)
+                            .ConfigureAwait(false);
 
-                            state.WaitForPath = segmentPath;
-                            job = await _transcodingJobHelper.StartFfMpeg(
-                                state,
-                                playlistPath,
-                                GetCommandLineArguments(playlistPath, state, false, segmentId),
-                                Request,
-                                TranscodingJobType,
-                                cancellationTokenSource).ConfigureAwait(false);
-                        }
-                        catch
+                        if (currentTranscodingIndex.HasValue)
                         {
-                            state.Dispose();
-                            throw;
+                            DeleteLastFile(playlistPath, segmentExtension, 0);
                         }
 
-                        // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false);
+                        streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
+
+                        state.WaitForPath = segmentPath;
+                        job = await _transcodingJobHelper.StartFfMpeg(
+                            state,
+                            playlistPath,
+                            GetCommandLineArguments(playlistPath, state, false, segmentId),
+                            Request,
+                            TranscodingJobType,
+                            cancellationTokenSource).ConfigureAwait(false);
                     }
-                    else
+                    catch
                     {
-                        job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
-                        if (job?.TranscodingThrottler is not null)
-                        {
-                            await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
-                        }
+                        state.Dispose();
+                        throw;
                     }
+
+                    // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false);
                 }
-            }
-            finally
-            {
-                if (!released)
+                else
                 {
-                    transcodingLock.Release();
+                    job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
+                    if (job?.TranscodingThrottler is not null)
+                    {
+                        await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
+                    }
                 }
             }
-
-            _logger.LogDebug("returning {0} [general case]", segmentPath);
-            job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
-            return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
         }
-
-        private static double[] GetSegmentLengths(StreamState state)
-            => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
-
-        internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength)
+        finally
         {
-            var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
-            var wholeSegments = runtimeTicks / segmentLengthTicks;
-            var remainingTicks = runtimeTicks % segmentLengthTicks;
-
-            var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1);
-            var segments = new double[segmentsLen];
-            for (int i = 0; i < wholeSegments; i++)
+            if (!released)
             {
-                segments[i] = segmentlength;
+                transcodingLock.Release();
             }
+        }
 
-            if (remainingTicks != 0)
-            {
-                segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds;
-            }
+        _logger.LogDebug("returning {0} [general case]", segmentPath);
+        job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
+        return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
+    }
+
+    private static double[] GetSegmentLengths(StreamState state)
+        => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
 
-            return segments;
+    internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength)
+    {
+        var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
+        var wholeSegments = runtimeTicks / segmentLengthTicks;
+        var remainingTicks = runtimeTicks % segmentLengthTicks;
+
+        var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1);
+        var segments = new double[segmentsLen];
+        for (int i = 0; i < wholeSegments; i++)
+        {
+            segments[i] = segmentlength;
         }
 
-        private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
+        if (remainingTicks != 0)
         {
-            var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
-            var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
+            segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds;
+        }
 
-            if (state.BaseRequest.BreakOnNonKeyFrames)
-            {
-                // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe
-                //        breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable
-                //        to produce a missing part of video stream before first keyframe is encountered, which may lead to
-                //        awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js
-                _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request");
-                state.BaseRequest.BreakOnNonKeyFrames = false;
-            }
+        return segments;
+    }
 
-            var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
+    private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
+    {
+        var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+        var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
 
-            var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
-            var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
-            var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
-            var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
-            var outputTsArg = outputPrefix + "%d" + outputExtension;
+        if (state.BaseRequest.BreakOnNonKeyFrames)
+        {
+            // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe
+            //        breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable
+            //        to produce a missing part of video stream before first keyframe is encountered, which may lead to
+            //        awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js
+            _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request");
+            state.BaseRequest.BreakOnNonKeyFrames = false;
+        }
 
-            var segmentFormat = string.Empty;
-            var segmentContainer = outputExtension.TrimStart('.');
-            var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
+        var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
 
-            if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
-            {
-                segmentFormat = "mpegts";
-            }
-            else if (string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
-            {
-                var outputFmp4HeaderArg = OperatingSystem.IsWindows() switch
-                {
-                    // on Windows, the path of fmp4 header file needs to be configured
-                    true => " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"",
-                    // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
-                    false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""
-                };
+        var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
+        var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+        var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
+        var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
+        var outputTsArg = outputPrefix + "%d" + outputExtension;
 
-                segmentFormat = "fmp4" + outputFmp4HeaderArg;
-            }
-            else
+        var segmentFormat = string.Empty;
+        var segmentContainer = outputExtension.TrimStart('.');
+        var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
+
+        if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
+        {
+            segmentFormat = "mpegts";
+        }
+        else if (string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
+        {
+            var outputFmp4HeaderArg = OperatingSystem.IsWindows() switch
             {
-                _logger.LogError("Invalid HLS segment container: {SegmentContainer}, default to mpegts", segmentContainer);
-                segmentFormat = "mpegts";
-            }
+                // on Windows, the path of fmp4 header file needs to be configured
+                true => " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"",
+                // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
+                false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""
+            };
 
-            var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
-                ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
-                : "128";
+            segmentFormat = "fmp4" + outputFmp4HeaderArg;
+        }
+        else
+        {
+            _logger.LogError("Invalid HLS segment container: {SegmentContainer}, default to mpegts", segmentContainer);
+            segmentFormat = "mpegts";
+        }
 
-            var baseUrlParam = string.Empty;
-            if (isEventPlaylist)
-            {
-                baseUrlParam = string.Format(
-                    CultureInfo.InvariantCulture,
-                    " -hls_base_url \"hls/{0}/\"",
-                    Path.GetFileNameWithoutExtension(outputPath));
-            }
+        var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
+            ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
+            : "128";
 
-            return string.Format(
+        var baseUrlParam = string.Empty;
+        if (isEventPlaylist)
+        {
+            baseUrlParam = string.Format(
                 CultureInfo.InvariantCulture,
-                "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{12}\" -hls_playlist_type {11} -hls_list_size 0 -y \"{13}\"",
-                inputModifier,
-                _encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
-                threads,
-                mapArgs,
-                GetVideoArguments(state, startNumber, isEventPlaylist),
-                GetAudioArguments(state),
-                maxMuxingQueueSize,
-                state.SegmentLength.ToString(CultureInfo.InvariantCulture),
-                segmentFormat,
-                startNumber.ToString(CultureInfo.InvariantCulture),
-                baseUrlParam,
-                isEventPlaylist ? "event" : "vod",
-                outputTsArg,
-                outputPath).Trim();
+                " -hls_base_url \"hls/{0}/\"",
+                Path.GetFileNameWithoutExtension(outputPath));
         }
 
-        /// <summary>
-        /// Gets the audio arguments for transcoding.
-        /// </summary>
-        /// <param name="state">The <see cref="StreamState"/>.</param>
-        /// <returns>The command line arguments for audio transcoding.</returns>
-        private string GetAudioArguments(StreamState state)
+        return string.Format(
+            CultureInfo.InvariantCulture,
+            "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{12}\" -hls_playlist_type {11} -hls_list_size 0 -y \"{13}\"",
+            inputModifier,
+            _encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
+            threads,
+            mapArgs,
+            GetVideoArguments(state, startNumber, isEventPlaylist),
+            GetAudioArguments(state),
+            maxMuxingQueueSize,
+            state.SegmentLength.ToString(CultureInfo.InvariantCulture),
+            segmentFormat,
+            startNumber.ToString(CultureInfo.InvariantCulture),
+            baseUrlParam,
+            isEventPlaylist ? "event" : "vod",
+            outputTsArg,
+            outputPath).Trim();
+    }
+
+    /// <summary>
+    /// Gets the audio arguments for transcoding.
+    /// </summary>
+    /// <param name="state">The <see cref="StreamState"/>.</param>
+    /// <returns>The command line arguments for audio transcoding.</returns>
+    private string GetAudioArguments(StreamState state)
+    {
+        if (state.AudioStream is null)
         {
-            if (state.AudioStream is null)
-            {
-                return string.Empty;
-            }
+            return string.Empty;
+        }
 
-            var audioCodec = _encodingHelper.GetAudioEncoder(state);
+        var audioCodec = _encodingHelper.GetAudioEncoder(state);
 
-            if (!state.IsOutputVideo)
+        if (!state.IsOutputVideo)
+        {
+            if (EncodingHelper.IsCopyCodec(audioCodec))
             {
-                if (EncodingHelper.IsCopyCodec(audioCodec))
-                {
-                    var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
-
-                    return "-acodec copy -strict -2" + bitStreamArgs;
-                }
-
-                var audioTranscodeParams = string.Empty;
-
-                audioTranscodeParams += "-acodec " + audioCodec;
+                var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
 
-                if (state.OutputAudioBitrate.HasValue)
-                {
-                    audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture);
-                }
+                return "-acodec copy -strict -2" + bitStreamArgs;
+            }
 
-                if (state.OutputAudioChannels.HasValue)
-                {
-                    audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture);
-                }
+            var audioTranscodeParams = string.Empty;
 
-                if (state.OutputAudioSampleRate.HasValue)
-                {
-                    audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
-                }
+            audioTranscodeParams += "-acodec " + audioCodec;
 
-                audioTranscodeParams += " -vn";
-                return audioTranscodeParams;
+            if (state.OutputAudioBitrate.HasValue)
+            {
+                audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture);
             }
 
-            // dts, flac, opus and truehd are experimental in mp4 muxer
-            var strictArgs = string.Empty;
-
-            if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase))
+            if (state.OutputAudioChannels.HasValue)
             {
-                strictArgs = " -strict -2";
+                audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture);
             }
 
-            if (EncodingHelper.IsCopyCodec(audioCodec))
+            if (state.OutputAudioSampleRate.HasValue)
             {
-                var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
-                var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
-                var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs;
+                audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
+            }
 
-                if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
-                {
-                    return copyArgs + " -copypriorss:a:0 0";
-                }
+            audioTranscodeParams += " -vn";
+            return audioTranscodeParams;
+        }
 
-                return copyArgs;
-            }
+        // dts, flac, opus and truehd are experimental in mp4 muxer
+        var strictArgs = string.Empty;
 
-            var args = "-codec:a:0 " + audioCodec + strictArgs;
+        if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase))
+        {
+            strictArgs = " -strict -2";
+        }
 
-            var channels = state.OutputAudioChannels;
+        if (EncodingHelper.IsCopyCodec(audioCodec))
+        {
+            var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+            var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
+            var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs;
 
-            if (channels.HasValue
-                && (channels.Value != 2
-                    || (state.AudioStream is not null
-                        && state.AudioStream.Channels.HasValue
-                        && state.AudioStream.Channels.Value > 5
-                        && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None)))
+            if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
             {
-                args += " -ac " + channels.Value;
+                return copyArgs + " -copypriorss:a:0 0";
             }
 
-            var bitrate = state.OutputAudioBitrate;
+            return copyArgs;
+        }
 
-            if (bitrate.HasValue)
-            {
-                args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture);
-            }
+        var args = "-codec:a:0 " + audioCodec + strictArgs;
 
-            if (state.OutputAudioSampleRate.HasValue)
-            {
-                args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
-            }
+        var channels = state.OutputAudioChannels;
 
-            args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions);
+        if (channels.HasValue
+            && (channels.Value != 2
+                || (state.AudioStream is not null
+                    && state.AudioStream.Channels.HasValue
+                    && state.AudioStream.Channels.Value > 5
+                    && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None)))
+        {
+            args += " -ac " + channels.Value;
+        }
 
-            return args;
+        var bitrate = state.OutputAudioBitrate;
+
+        if (bitrate.HasValue)
+        {
+            args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture);
         }
 
-        /// <summary>
-        /// Gets the video arguments for transcoding.
-        /// </summary>
-        /// <param name="state">The <see cref="StreamState"/>.</param>
-        /// <param name="startNumber">The first number in the hls sequence.</param>
-        /// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
-        /// <returns>The command line arguments for video transcoding.</returns>
-        private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist)
+        if (state.OutputAudioSampleRate.HasValue)
         {
-            if (state.VideoStream is null)
-            {
-                return string.Empty;
-            }
+            args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
+        }
 
-            if (!state.IsOutputVideo)
-            {
-                return string.Empty;
-            }
+        args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions);
 
-            var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+        return args;
+    }
 
-            var args = "-codec:v:0 " + codec;
+    /// <summary>
+    /// Gets the video arguments for transcoding.
+    /// </summary>
+    /// <param name="state">The <see cref="StreamState"/>.</param>
+    /// <param name="startNumber">The first number in the hls sequence.</param>
+    /// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
+    /// <returns>The command line arguments for video transcoding.</returns>
+    private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist)
+    {
+        if (state.VideoStream is null)
+        {
+            return string.Empty;
+        }
 
-            if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
-            {
-                if (EncodingHelper.IsCopyCodec(codec)
-                    && (string.Equals(state.VideoStream.VideoRangeType, "DOVI", StringComparison.OrdinalIgnoreCase)
-                        || string.Equals(state.VideoStream.CodecTag, "dovi", StringComparison.OrdinalIgnoreCase)
-                        || string.Equals(state.VideoStream.CodecTag, "dvh1", StringComparison.OrdinalIgnoreCase)
-                        || string.Equals(state.VideoStream.CodecTag, "dvhe", StringComparison.OrdinalIgnoreCase)))
-                {
-                    // Prefer dvh1 to dvhe
-                    args += " -tag:v:0 dvh1 -strict -2";
-                }
-                else
-                {
-                    // Prefer hvc1 to hev1
-                    args += " -tag:v:0 hvc1";
-                }
-            }
+        if (!state.IsOutputVideo)
+        {
+            return string.Empty;
+        }
 
-            // if  (state.EnableMpegtsM2TsMode)
-            // {
-            //     args += " -mpegts_m2ts_mode 1";
-            // }
+        var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
 
-            // See if we can save come cpu cycles by avoiding encoding.
-            if (EncodingHelper.IsCopyCodec(codec))
-            {
-                // If h264_mp4toannexb is ever added, do not use it for live tv.
-                if (state.VideoStream is not null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
-                {
-                    string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream);
-                    if (!string.IsNullOrEmpty(bitStreamArgs))
-                    {
-                        args += " " + bitStreamArgs;
-                    }
-                }
+        var args = "-codec:v:0 " + codec;
 
-                args += " -start_at_zero";
+        if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+        {
+            if (EncodingHelper.IsCopyCodec(codec)
+                && (string.Equals(state.VideoStream.VideoRangeType, "DOVI", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(state.VideoStream.CodecTag, "dovi", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(state.VideoStream.CodecTag, "dvh1", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(state.VideoStream.CodecTag, "dvhe", StringComparison.OrdinalIgnoreCase)))
+            {
+                // Prefer dvh1 to dvhe
+                args += " -tag:v:0 dvh1 -strict -2";
             }
             else
             {
-                args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
+                // Prefer hvc1 to hev1
+                args += " -tag:v:0 hvc1";
+            }
+        }
 
-                // Set the key frame params for video encoding to match the hls segment time.
-                args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber);
+        // if  (state.EnableMpegtsM2TsMode)
+        // {
+        //     args += " -mpegts_m2ts_mode 1";
+        // }
 
-                // Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
-                if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
+        // See if we can save come cpu cycles by avoiding encoding.
+        if (EncodingHelper.IsCopyCodec(codec))
+        {
+            // If h264_mp4toannexb is ever added, do not use it for live tv.
+            if (state.VideoStream is not null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
+            {
+                string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream);
+                if (!string.IsNullOrEmpty(bitStreamArgs))
                 {
-                    args += " -bf 0";
+                    args += " " + bitStreamArgs;
                 }
+            }
 
-                // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0";
-
-                // video processing filters.
-                args += _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec);
+            args += " -start_at_zero";
+        }
+        else
+        {
+            args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
 
-                // -start_at_zero is necessary to use with -ss when seeking,
-                // otherwise the target position cannot be determined.
-                if (state.SubtitleStream is not null)
-                {
-                    // Disable start_at_zero for external graphical subs
-                    if (!(state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream))
-                    {
-                        args += " -start_at_zero";
-                    }
-                }
-            }
+            // Set the key frame params for video encoding to match the hls segment time.
+            args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber);
 
-            // TODO why was this not enabled for VOD?
-            if (isEventPlaylist)
+            // Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
+            if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
             {
-                args += " -flags -global_header";
+                args += " -bf 0";
             }
 
-            if (!string.IsNullOrEmpty(state.OutputVideoSync))
-            {
-                args += " -vsync " + state.OutputVideoSync;
-            }
+            // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0";
 
-            args += _encodingHelper.GetOutputFFlags(state);
+            // video processing filters.
+            args += _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec);
 
-            return args;
+            // -start_at_zero is necessary to use with -ss when seeking,
+            // otherwise the target position cannot be determined.
+            if (state.SubtitleStream is not null)
+            {
+                // Disable start_at_zero for external graphical subs
+                if (!(state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream))
+                {
+                    args += " -start_at_zero";
+                }
+            }
         }
 
-        private string GetSegmentPath(StreamState state, string playlist, int index)
+        // TODO why was this not enabled for VOD?
+        if (isEventPlaylist)
         {
-            var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException($"Provided path ({playlist}) is not valid.", nameof(playlist));
-            var filename = Path.GetFileNameWithoutExtension(playlist);
+            args += " -flags -global_header";
+        }
 
-            return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer));
+        if (!string.IsNullOrEmpty(state.OutputVideoSync))
+        {
+            args += " -vsync " + state.OutputVideoSync;
         }
 
-        private async Task<ActionResult> GetSegmentResult(
-            StreamState state,
-            string playlistPath,
-            string segmentPath,
-            string segmentExtension,
-            int segmentIndex,
-            TranscodingJobDto? transcodingJob,
-            CancellationToken cancellationToken)
+        args += _encodingHelper.GetOutputFFlags(state);
+
+        return args;
+    }
+
+    private string GetSegmentPath(StreamState state, string playlist, int index)
+    {
+        var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException($"Provided path ({playlist}) is not valid.", nameof(playlist));
+        var filename = Path.GetFileNameWithoutExtension(playlist);
+
+        return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer));
+    }
+
+    private async Task<ActionResult> GetSegmentResult(
+        StreamState state,
+        string playlistPath,
+        string segmentPath,
+        string segmentExtension,
+        int segmentIndex,
+        TranscodingJobDto? transcodingJob,
+        CancellationToken cancellationToken)
+    {
+        var segmentExists = System.IO.File.Exists(segmentPath);
+        if (segmentExists)
         {
-            var segmentExists = System.IO.File.Exists(segmentPath);
-            if (segmentExists)
+            if (transcodingJob is not null && transcodingJob.HasExited)
             {
-                if (transcodingJob is not null && transcodingJob.HasExited)
-                {
-                    // Transcoding job is over, so assume all existing files are ready
-                    _logger.LogDebug("serving up {0} as transcode is over", segmentPath);
-                    return GetSegmentResult(state, segmentPath, transcodingJob);
-                }
+                // Transcoding job is over, so assume all existing files are ready
+                _logger.LogDebug("serving up {0} as transcode is over", segmentPath);
+                return GetSegmentResult(state, segmentPath, transcodingJob);
+            }
 
-                var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
+            var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
 
-                // If requested segment is less than transcoding position, we can't transcode backwards, so assume it's ready
-                if (segmentIndex < currentTranscodingIndex)
-                {
-                    _logger.LogDebug("serving up {0} as transcode index {1} is past requested point {2}", segmentPath, currentTranscodingIndex, segmentIndex);
-                    return GetSegmentResult(state, segmentPath, transcodingJob);
-                }
+            // If requested segment is less than transcoding position, we can't transcode backwards, so assume it's ready
+            if (segmentIndex < currentTranscodingIndex)
+            {
+                _logger.LogDebug("serving up {0} as transcode index {1} is past requested point {2}", segmentPath, currentTranscodingIndex, segmentIndex);
+                return GetSegmentResult(state, segmentPath, transcodingJob);
             }
+        }
 
-            var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1);
-            if (transcodingJob is not null)
+        var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1);
+        if (transcodingJob is not null)
+        {
+            while (!cancellationToken.IsCancellationRequested && !transcodingJob.HasExited)
             {
-                while (!cancellationToken.IsCancellationRequested && !transcodingJob.HasExited)
+                // To be considered ready, the segment file has to exist AND
+                // either the transcoding job should be done or next segment should also exist
+                if (segmentExists)
                 {
-                    // To be considered ready, the segment file has to exist AND
-                    // either the transcoding job should be done or next segment should also exist
-                    if (segmentExists)
-                    {
-                        if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath))
-                        {
-                            _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath);
-                            return GetSegmentResult(state, segmentPath, transcodingJob);
-                        }
-                    }
-                    else
+                    if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath))
                     {
-                        segmentExists = System.IO.File.Exists(segmentPath);
-                        if (segmentExists)
-                        {
-                            continue; // avoid unnecessary waiting if segment just became available
-                        }
+                        _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath);
+                        return GetSegmentResult(state, segmentPath, transcodingJob);
                     }
-
-                    await Task.Delay(100, cancellationToken).ConfigureAwait(false);
-                }
-
-                if (!System.IO.File.Exists(segmentPath))
-                {
-                    _logger.LogWarning("cannot serve {0} as transcoding quit before we got there", segmentPath);
                 }
                 else
                 {
-                    _logger.LogDebug("serving {0} as it's on disk and transcoding stopped", segmentPath);
+                    segmentExists = System.IO.File.Exists(segmentPath);
+                    if (segmentExists)
+                    {
+                        continue; // avoid unnecessary waiting if segment just became available
+                    }
                 }
 
-                cancellationToken.ThrowIfCancellationRequested();
+                await Task.Delay(100, cancellationToken).ConfigureAwait(false);
+            }
+
+            if (!System.IO.File.Exists(segmentPath))
+            {
+                _logger.LogWarning("cannot serve {0} as transcoding quit before we got there", segmentPath);
             }
             else
             {
-                _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath);
+                _logger.LogDebug("serving {0} as it's on disk and transcoding stopped", segmentPath);
             }
 
-            return GetSegmentResult(state, segmentPath, transcodingJob);
+            cancellationToken.ThrowIfCancellationRequested();
         }
-
-        private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJobDto? transcodingJob)
+        else
         {
-            var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks;
-
-            Response.OnCompleted(() =>
-            {
-                _logger.LogDebug("Finished serving {SegmentPath}", segmentPath);
-                if (transcodingJob is not null)
-                {
-                    transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks);
-                    _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob);
-                }
+            _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath);
+        }
 
-                return Task.CompletedTask;
-            });
+        return GetSegmentResult(state, segmentPath, transcodingJob);
+    }
 
-            return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath));
-        }
+    private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJobDto? transcodingJob)
+    {
+        var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks;
 
-        private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension)
+        Response.OnCompleted(() =>
         {
-            var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType);
-
-            if (job is null || job.HasExited)
+            _logger.LogDebug("Finished serving {SegmentPath}", segmentPath);
+            if (transcodingJob is not null)
             {
-                return null;
+                transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks);
+                _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob);
             }
 
-            var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem);
+            return Task.CompletedTask;
+        });
 
-            if (file is null)
-            {
-                return null;
-            }
-
-            var playlistFilename = Path.GetFileNameWithoutExtension(playlist);
+        return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath));
+    }
 
-            var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length);
+    private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension)
+    {
+        var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType);
 
-            return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture);
+        if (job is null || job.HasExited)
+        {
+            return null;
         }
 
-        private static FileSystemMetadata? GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem)
+        var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem);
+
+        if (file is null)
         {
-            var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException("Path can't be a root directory.", nameof(playlist));
+            return null;
+        }
 
-            var filePrefix = Path.GetFileNameWithoutExtension(playlist);
+        var playlistFilename = Path.GetFileNameWithoutExtension(playlist);
 
-            try
-            {
-                return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false)
-                    .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase))
-                    .OrderByDescending(fileSystem.GetLastWriteTimeUtc)
-                    .FirstOrDefault();
-            }
-            catch (IOException)
-            {
-                return null;
-            }
-        }
+        var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length);
+
+        return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture);
+    }
+
+    private static FileSystemMetadata? GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem)
+    {
+        var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException("Path can't be a root directory.", nameof(playlist));
 
-        private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount)
+        var filePrefix = Path.GetFileNameWithoutExtension(playlist);
+
+        try
+        {
+            return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false)
+                .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase))
+                .OrderByDescending(fileSystem.GetLastWriteTimeUtc)
+                .FirstOrDefault();
+        }
+        catch (IOException)
         {
-            var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem);
+            return null;
+        }
+    }
 
-            if (file is not null)
-            {
-                DeleteFile(file.FullName, retryCount);
-            }
+    private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount)
+    {
+        var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem);
+
+        if (file is not null)
+        {
+            DeleteFile(file.FullName, retryCount);
         }
+    }
 
-        private void DeleteFile(string path, int retryCount)
+    private void DeleteFile(string path, int retryCount)
+    {
+        if (retryCount >= 5)
         {
-            if (retryCount >= 5)
-            {
-                return;
-            }
+            return;
+        }
 
-            _logger.LogDebug("Deleting partial HLS file {Path}", path);
+        _logger.LogDebug("Deleting partial HLS file {Path}", path);
 
-            try
-            {
-                _fileSystem.DeleteFile(path);
-            }
-            catch (IOException ex)
-            {
-                _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
+        try
+        {
+            _fileSystem.DeleteFile(path);
+        }
+        catch (IOException ex)
+        {
+            _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
 
-                var task = Task.Delay(100);
-                task.Wait();
-                DeleteFile(path, retryCount + 1);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
-            }
+            var task = Task.Delay(100);
+            task.Wait();
+            DeleteFile(path, retryCount + 1);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
         }
     }
 }

+ 142 - 143
Jellyfin.Api/Controllers/EnvironmentController.cs

@@ -12,186 +12,185 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Environment Controller.
+/// </summary>
+[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+public class EnvironmentController : BaseJellyfinApiController
 {
+    private const char UncSeparator = '\\';
+    private const string UncStartPrefix = @"\\";
+
+    private readonly IFileSystem _fileSystem;
+    private readonly ILogger<EnvironmentController> _logger;
+
     /// <summary>
-    /// Environment Controller.
+    /// Initializes a new instance of the <see cref="EnvironmentController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
-    public class EnvironmentController : BaseJellyfinApiController
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{EnvironmentController}"/> interface.</param>
+    public EnvironmentController(IFileSystem fileSystem, ILogger<EnvironmentController> logger)
     {
-        private const char UncSeparator = '\\';
-        private const string UncStartPrefix = @"\\";
-
-        private readonly IFileSystem _fileSystem;
-        private readonly ILogger<EnvironmentController> _logger;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="EnvironmentController"/> class.
-        /// </summary>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{EnvironmentController}"/> interface.</param>
-        public EnvironmentController(IFileSystem fileSystem, ILogger<EnvironmentController> logger)
-        {
-            _fileSystem = fileSystem;
-            _logger = logger;
-        }
+        _fileSystem = fileSystem;
+        _logger = logger;
+    }
 
-        /// <summary>
-        /// Gets the contents of a given directory in the file system.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="includeFiles">An optional filter to include or exclude files from the results. true/false.</param>
-        /// <param name="includeDirectories">An optional filter to include or exclude folders from the results. true/false.</param>
-        /// <response code="200">Directory contents returned.</response>
-        /// <returns>Directory contents.</returns>
-        [HttpGet("DirectoryContents")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public IEnumerable<FileSystemEntryInfo> GetDirectoryContents(
-            [FromQuery, Required] string path,
-            [FromQuery] bool includeFiles = false,
-            [FromQuery] bool includeDirectories = false)
+    /// <summary>
+    /// Gets the contents of a given directory in the file system.
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <param name="includeFiles">An optional filter to include or exclude files from the results. true/false.</param>
+    /// <param name="includeDirectories">An optional filter to include or exclude folders from the results. true/false.</param>
+    /// <response code="200">Directory contents returned.</response>
+    /// <returns>Directory contents.</returns>
+    [HttpGet("DirectoryContents")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public IEnumerable<FileSystemEntryInfo> GetDirectoryContents(
+        [FromQuery, Required] string path,
+        [FromQuery] bool includeFiles = false,
+        [FromQuery] bool includeDirectories = false)
+    {
+        if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase)
+            && path.LastIndexOf(UncSeparator) == 1)
         {
-            if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase)
-                && path.LastIndexOf(UncSeparator) == 1)
-            {
-                return Array.Empty<FileSystemEntryInfo>();
-            }
+            return Array.Empty<FileSystemEntryInfo>();
+        }
 
-            var entries =
-                _fileSystem.GetFileSystemEntries(path)
-                    .Where(i => (i.IsDirectory && includeDirectories) || (!i.IsDirectory && includeFiles))
-                    .OrderBy(i => i.FullName);
+        var entries =
+            _fileSystem.GetFileSystemEntries(path)
+                .Where(i => (i.IsDirectory && includeDirectories) || (!i.IsDirectory && includeFiles))
+                .OrderBy(i => i.FullName);
 
-            return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File));
-        }
+        return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File));
+    }
 
-        /// <summary>
-        /// Validates path.
-        /// </summary>
-        /// <param name="validatePathDto">Validate request object.</param>
-        /// <response code="204">Path validated.</response>
-        /// <response code="404">Path not found.</response>
-        /// <returns>Validation status.</returns>
-        [HttpPost("ValidatePath")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto)
+    /// <summary>
+    /// Validates path.
+    /// </summary>
+    /// <param name="validatePathDto">Validate request object.</param>
+    /// <response code="204">Path validated.</response>
+    /// <response code="404">Path not found.</response>
+    /// <returns>Validation status.</returns>
+    [HttpPost("ValidatePath")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto)
+    {
+        if (validatePathDto.IsFile.HasValue)
         {
-            if (validatePathDto.IsFile.HasValue)
+            if (validatePathDto.IsFile.Value)
             {
-                if (validatePathDto.IsFile.Value)
+                if (!System.IO.File.Exists(validatePathDto.Path))
                 {
-                    if (!System.IO.File.Exists(validatePathDto.Path))
-                    {
-                        return NotFound();
-                    }
-                }
-                else
-                {
-                    if (!Directory.Exists(validatePathDto.Path))
-                    {
-                        return NotFound();
-                    }
+                    return NotFound();
                 }
             }
             else
             {
-                if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path))
+                if (!Directory.Exists(validatePathDto.Path))
                 {
                     return NotFound();
                 }
+            }
+        }
+        else
+        {
+            if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path))
+            {
+                return NotFound();
+            }
 
-                if (validatePathDto.ValidateWritable)
+            if (validatePathDto.ValidateWritable)
+            {
+                if (validatePathDto.Path is null)
                 {
-                    if (validatePathDto.Path is null)
-                    {
-                        throw new ResourceNotFoundException(nameof(validatePathDto.Path));
-                    }
+                    throw new ResourceNotFoundException(nameof(validatePathDto.Path));
+                }
 
-                    var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString());
-                    try
-                    {
-                        System.IO.File.WriteAllText(file, string.Empty);
-                    }
-                    finally
+                var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString());
+                try
+                {
+                    System.IO.File.WriteAllText(file, string.Empty);
+                }
+                finally
+                {
+                    if (System.IO.File.Exists(file))
                     {
-                        if (System.IO.File.Exists(file))
-                        {
-                            System.IO.File.Delete(file);
-                        }
+                        System.IO.File.Delete(file);
                     }
                 }
             }
-
-            return NoContent();
         }
 
-        /// <summary>
-        /// Gets network paths.
-        /// </summary>
-        /// <response code="200">Empty array returned.</response>
-        /// <returns>List of entries.</returns>
-        [Obsolete("This endpoint is obsolete.")]
-        [HttpGet("NetworkShares")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares()
-        {
-            _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares");
-            return Array.Empty<FileSystemEntryInfo>();
-        }
+        return NoContent();
+    }
 
-        /// <summary>
-        /// Gets available drives from the server's file system.
-        /// </summary>
-        /// <response code="200">List of entries returned.</response>
-        /// <returns>List of entries.</returns>
-        [HttpGet("Drives")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public IEnumerable<FileSystemEntryInfo> GetDrives()
-        {
-            return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory));
-        }
+    /// <summary>
+    /// Gets network paths.
+    /// </summary>
+    /// <response code="200">Empty array returned.</response>
+    /// <returns>List of entries.</returns>
+    [Obsolete("This endpoint is obsolete.")]
+    [HttpGet("NetworkShares")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares()
+    {
+        _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares");
+        return Array.Empty<FileSystemEntryInfo>();
+    }
 
-        /// <summary>
-        /// Gets the parent path of a given path.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <returns>Parent path.</returns>
-        [HttpGet("ParentPath")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<string?> GetParentPath([FromQuery, Required] string path)
+    /// <summary>
+    /// Gets available drives from the server's file system.
+    /// </summary>
+    /// <response code="200">List of entries returned.</response>
+    /// <returns>List of entries.</returns>
+    [HttpGet("Drives")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public IEnumerable<FileSystemEntryInfo> GetDrives()
+    {
+        return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory));
+    }
+
+    /// <summary>
+    /// Gets the parent path of a given path.
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <returns>Parent path.</returns>
+    [HttpGet("ParentPath")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<string?> GetParentPath([FromQuery, Required] string path)
+    {
+        string? parent = Path.GetDirectoryName(path);
+        if (string.IsNullOrEmpty(parent))
         {
-            string? parent = Path.GetDirectoryName(path);
-            if (string.IsNullOrEmpty(parent))
+            // Check if unc share
+            var index = path.LastIndexOf(UncSeparator);
+
+            if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0)
             {
-                // Check if unc share
-                var index = path.LastIndexOf(UncSeparator);
+                parent = path.Substring(0, index);
 
-                if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0)
+                if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator)))
                 {
-                    parent = path.Substring(0, index);
-
-                    if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator)))
-                    {
-                        parent = null;
-                    }
+                    parent = null;
                 }
             }
-
-            return parent;
         }
 
-        /// <summary>
-        /// Get Default directory browser.
-        /// </summary>
-        /// <response code="200">Default directory browser returned.</response>
-        /// <returns>Default directory browser.</returns>
-        [HttpGet("DefaultDirectoryBrowser")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<DefaultDirectoryBrowserInfoDto> GetDefaultDirectoryBrowser()
-        {
-            return new DefaultDirectoryBrowserInfoDto();
-        }
+        return parent;
+    }
+
+    /// <summary>
+    /// Get Default directory browser.
+    /// </summary>
+    /// <response code="200">Default directory browser returned.</response>
+    /// <returns>Default directory browser.</returns>
+    [HttpGet("DefaultDirectoryBrowser")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<DefaultDirectoryBrowserInfoDto> GetDefaultDirectoryBrowser()
+    {
+        return new DefaultDirectoryBrowserInfoDto();
     }
 }

+ 179 - 181
Jellyfin.Api/Controllers/FilterController.cs

@@ -1,6 +1,5 @@
 using System;
 using System.Linq;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Dto;
@@ -12,205 +11,204 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Filters controller.
+/// </summary>
+[Route("")]
+[Authorize]
+public class FilterController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IUserManager _userManager;
+
     /// <summary>
-    /// Filters controller.
+    /// Initializes a new instance of the <see cref="FilterController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class FilterController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    public FilterController(ILibraryManager libraryManager, IUserManager userManager)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="FilterController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        public FilterController(ILibraryManager libraryManager, IUserManager userManager)
+        _libraryManager = libraryManager;
+        _userManager = userManager;
+    }
+
+    /// <summary>
+    /// Gets legacy query filters.
+    /// </summary>
+    /// <param name="userId">Optional. User id.</param>
+    /// <param name="parentId">Optional. Parent id.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <response code="200">Legacy filters retrieved.</response>
+    /// <returns>Legacy query filters.</returns>
+    [HttpGet("Items/Filters")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
+        [FromQuery] Guid? userId,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+
+        BaseItem? item = null;
+        if (includeItemTypes.Length != 1
+            || !(includeItemTypes[0] == BaseItemKind.BoxSet
+                 || includeItemTypes[0] == BaseItemKind.Playlist
+                 || includeItemTypes[0] == BaseItemKind.Trailer
+                 || includeItemTypes[0] == BaseItemKind.Program))
         {
-            _libraryManager = libraryManager;
-            _userManager = userManager;
+            item = _libraryManager.GetParentItem(parentId, user?.Id);
         }
 
-        /// <summary>
-        /// Gets legacy query filters.
-        /// </summary>
-        /// <param name="userId">Optional. User id.</param>
-        /// <param name="parentId">Optional. Parent id.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <response code="200">Legacy filters retrieved.</response>
-        /// <returns>Legacy query filters.</returns>
-        [HttpGet("Items/Filters")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
-            [FromQuery] Guid? userId,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes)
+        var query = new InternalItemsQuery
         {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-
-            BaseItem? item = null;
-            if (includeItemTypes.Length != 1
-                || !(includeItemTypes[0] == BaseItemKind.BoxSet
-                     || includeItemTypes[0] == BaseItemKind.Playlist
-                     || includeItemTypes[0] == BaseItemKind.Trailer
-                     || includeItemTypes[0] == BaseItemKind.Program))
-            {
-                item = _libraryManager.GetParentItem(parentId, user?.Id);
-            }
-
-            var query = new InternalItemsQuery
-            {
-                User = user,
-                MediaTypes = mediaTypes,
-                IncludeItemTypes = includeItemTypes,
-                Recursive = true,
-                EnableTotalRecordCount = false,
-                DtoOptions = new DtoOptions
-                {
-                    Fields = new[] { ItemFields.Genres, ItemFields.Tags },
-                    EnableImages = false,
-                    EnableUserData = false
-                }
-            };
-
-            if (item is not Folder folder)
+            User = user,
+            MediaTypes = mediaTypes,
+            IncludeItemTypes = includeItemTypes,
+            Recursive = true,
+            EnableTotalRecordCount = false,
+            DtoOptions = new DtoOptions
             {
-                return new QueryFiltersLegacy();
+                Fields = new[] { ItemFields.Genres, ItemFields.Tags },
+                EnableImages = false,
+                EnableUserData = false
             }
+        };
 
-            var itemList = folder.GetItemList(query);
-            return new QueryFiltersLegacy
-            {
-                Years = itemList.Select(i => i.ProductionYear ?? -1)
-                    .Where(i => i > 0)
-                    .Distinct()
-                    .Order()
-                    .ToArray(),
-
-                Genres = itemList.SelectMany(i => i.Genres)
-                    .DistinctNames()
-                    .Order()
-                    .ToArray(),
-
-                Tags = itemList
-                    .SelectMany(i => i.Tags)
-                    .Distinct(StringComparer.OrdinalIgnoreCase)
-                    .Order()
-                    .ToArray(),
-
-                OfficialRatings = itemList
-                    .Select(i => i.OfficialRating)
-                    .Where(i => !string.IsNullOrWhiteSpace(i))
-                    .Distinct(StringComparer.OrdinalIgnoreCase)
-                    .Order()
-                    .ToArray()
-            };
+        if (item is not Folder folder)
+        {
+            return new QueryFiltersLegacy();
         }
 
-        /// <summary>
-        /// Gets query filters.
-        /// </summary>
-        /// <param name="userId">Optional. User id.</param>
-        /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="isAiring">Optional. Is item airing.</param>
-        /// <param name="isMovie">Optional. Is item movie.</param>
-        /// <param name="isSports">Optional. Is item sports.</param>
-        /// <param name="isKids">Optional. Is item kids.</param>
-        /// <param name="isNews">Optional. Is item news.</param>
-        /// <param name="isSeries">Optional. Is item series.</param>
-        /// <param name="recursive">Optional. Search recursive.</param>
-        /// <response code="200">Filters retrieved.</response>
-        /// <returns>Query filters.</returns>
-        [HttpGet("Items/Filters2")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryFilters> GetQueryFilters(
-            [FromQuery] Guid? userId,
-            [FromQuery] Guid? parentId,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
-            [FromQuery] bool? isAiring,
-            [FromQuery] bool? isMovie,
-            [FromQuery] bool? isSports,
-            [FromQuery] bool? isKids,
-            [FromQuery] bool? isNews,
-            [FromQuery] bool? isSeries,
-            [FromQuery] bool? recursive)
+        var itemList = folder.GetItemList(query);
+        return new QueryFiltersLegacy
         {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-
-            BaseItem? parentItem = null;
-            if (includeItemTypes.Length == 1
-                && (includeItemTypes[0] == BaseItemKind.BoxSet
-                    || includeItemTypes[0] == BaseItemKind.Playlist
-                    || includeItemTypes[0] == BaseItemKind.Trailer
-                    || includeItemTypes[0] == BaseItemKind.Program))
-            {
-                parentItem = null;
-            }
-            else if (parentId.HasValue)
-            {
-                parentItem = _libraryManager.GetItemById(parentId.Value);
-            }
+            Years = itemList.Select(i => i.ProductionYear ?? -1)
+                .Where(i => i > 0)
+                .Distinct()
+                .Order()
+                .ToArray(),
+
+            Genres = itemList.SelectMany(i => i.Genres)
+                .DistinctNames()
+                .Order()
+                .ToArray(),
+
+            Tags = itemList
+                .SelectMany(i => i.Tags)
+                .Distinct(StringComparer.OrdinalIgnoreCase)
+                .Order()
+                .ToArray(),
+
+            OfficialRatings = itemList
+                .Select(i => i.OfficialRating)
+                .Where(i => !string.IsNullOrWhiteSpace(i))
+                .Distinct(StringComparer.OrdinalIgnoreCase)
+                .Order()
+                .ToArray()
+        };
+    }
 
-            var filters = new QueryFilters();
-            var genreQuery = new InternalItemsQuery(user)
-            {
-                IncludeItemTypes = includeItemTypes,
-                DtoOptions = new DtoOptions
-                {
-                    Fields = Array.Empty<ItemFields>(),
-                    EnableImages = false,
-                    EnableUserData = false
-                },
-                IsAiring = isAiring,
-                IsMovie = isMovie,
-                IsSports = isSports,
-                IsKids = isKids,
-                IsNews = isNews,
-                IsSeries = isSeries
-            };
-
-            if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder)
-            {
-                genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
-            }
-            else
+    /// <summary>
+    /// Gets query filters.
+    /// </summary>
+    /// <param name="userId">Optional. User id.</param>
+    /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="isAiring">Optional. Is item airing.</param>
+    /// <param name="isMovie">Optional. Is item movie.</param>
+    /// <param name="isSports">Optional. Is item sports.</param>
+    /// <param name="isKids">Optional. Is item kids.</param>
+    /// <param name="isNews">Optional. Is item news.</param>
+    /// <param name="isSeries">Optional. Is item series.</param>
+    /// <param name="recursive">Optional. Search recursive.</param>
+    /// <response code="200">Filters retrieved.</response>
+    /// <returns>Query filters.</returns>
+    [HttpGet("Items/Filters2")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryFilters> GetQueryFilters(
+        [FromQuery] Guid? userId,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery] bool? isAiring,
+        [FromQuery] bool? isMovie,
+        [FromQuery] bool? isSports,
+        [FromQuery] bool? isKids,
+        [FromQuery] bool? isNews,
+        [FromQuery] bool? isSeries,
+        [FromQuery] bool? recursive)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+
+        BaseItem? parentItem = null;
+        if (includeItemTypes.Length == 1
+            && (includeItemTypes[0] == BaseItemKind.BoxSet
+                || includeItemTypes[0] == BaseItemKind.Playlist
+                || includeItemTypes[0] == BaseItemKind.Trailer
+                || includeItemTypes[0] == BaseItemKind.Program))
+        {
+            parentItem = null;
+        }
+        else if (parentId.HasValue)
+        {
+            parentItem = _libraryManager.GetItemById(parentId.Value);
+        }
+
+        var filters = new QueryFilters();
+        var genreQuery = new InternalItemsQuery(user)
+        {
+            IncludeItemTypes = includeItemTypes,
+            DtoOptions = new DtoOptions
             {
-                genreQuery.Parent = parentItem;
-            }
+                Fields = Array.Empty<ItemFields>(),
+                EnableImages = false,
+                EnableUserData = false
+            },
+            IsAiring = isAiring,
+            IsMovie = isMovie,
+            IsSports = isSports,
+            IsKids = isKids,
+            IsNews = isNews,
+            IsSeries = isSeries
+        };
+
+        if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder)
+        {
+            genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
+        }
+        else
+        {
+            genreQuery.Parent = parentItem;
+        }
 
-            if (includeItemTypes.Length == 1
-                && (includeItemTypes[0] == BaseItemKind.MusicAlbum
-                    || includeItemTypes[0] == BaseItemKind.MusicVideo
-                    || includeItemTypes[0] == BaseItemKind.MusicArtist
-                    || includeItemTypes[0] == BaseItemKind.Audio))
+        if (includeItemTypes.Length == 1
+            && (includeItemTypes[0] == BaseItemKind.MusicAlbum
+                || includeItemTypes[0] == BaseItemKind.MusicVideo
+                || includeItemTypes[0] == BaseItemKind.MusicArtist
+                || includeItemTypes[0] == BaseItemKind.Audio))
+        {
+            filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
             {
-                filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
-                {
-                    Name = i.Item.Name,
-                    Id = i.Item.Id
-                }).ToArray();
-            }
-            else
+                Name = i.Item.Name,
+                Id = i.Item.Id
+            }).ToArray();
+        }
+        else
+        {
+            filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair
             {
-                filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair
-                {
-                    Name = i.Item.Name,
-                    Id = i.Item.Id
-                }).ToArray();
-            }
-
-            return filters;
+                Name = i.Item.Name,
+                Id = i.Item.Id
+            }).ToArray();
         }
+
+        return filters;
     }
 }

+ 161 - 163
Jellyfin.Api/Controllers/GenresController.cs

@@ -1,7 +1,6 @@
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
@@ -18,194 +17,193 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Genre = MediaBrowser.Controller.Entities.Genre;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The genres controller.
+/// </summary>
+[Authorize]
+public class GenresController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IDtoService _dtoService;
+
     /// <summary>
-    /// The genres controller.
+    /// Initializes a new instance of the <see cref="GenresController"/> class.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class GenresController : BaseJellyfinApiController
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    public GenresController(
+        IUserManager userManager,
+        ILibraryManager libraryManager,
+        IDtoService dtoService)
     {
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="GenresController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        public GenresController(
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IDtoService dtoService)
-        {
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _dtoService = dtoService;
-        }
-
-        /// <summary>
-        /// Gets all genres from a given item, folder, or the entire library.
-        /// </summary>
-        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="searchTerm">The search term.</param>
-        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="userId">User id.</param>
-        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
-        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
-        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
-        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
-        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
-        /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
-        /// <response code="200">Genres returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the queryresult of genres.</returns>
-        [HttpGet]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetGenres(
-            [FromQuery] int? startIndex,
-            [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] bool? isFavorite,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
-            [FromQuery] bool? enableImages = true,
-            [FromQuery] bool enableTotalRecordCount = true)
-        {
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
+        _userManager = userManager;
+        _libraryManager = libraryManager;
+        _dtoService = dtoService;
+    }
 
-            User? user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
+    /// <summary>
+    /// Gets all genres from a given item, folder, or the entire library.
+    /// </summary>
+    /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="searchTerm">The search term.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+    /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <param name="userId">User id.</param>
+    /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+    /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+    /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+    /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
+    /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+    /// <param name="enableImages">Optional, include image information in output.</param>
+    /// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
+    /// <response code="200">Genres returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the queryresult of genres.</returns>
+    [HttpGet]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetGenres(
+        [FromQuery] int? startIndex,
+        [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] bool? isFavorite,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] Guid? userId,
+        [FromQuery] string? nameStartsWithOrGreater,
+        [FromQuery] string? nameStartsWith,
+        [FromQuery] string? nameLessThan,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder,
+        [FromQuery] bool? enableImages = true,
+        [FromQuery] bool enableTotalRecordCount = true)
+    {
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
 
-            var parentItem = _libraryManager.GetParentItem(parentId, userId);
+        User? user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
-            var query = new InternalItemsQuery(user)
-            {
-                ExcludeItemTypes = excludeItemTypes,
-                IncludeItemTypes = includeItemTypes,
-                StartIndex = startIndex,
-                Limit = limit,
-                IsFavorite = isFavorite,
-                NameLessThan = nameLessThan,
-                NameStartsWith = nameStartsWith,
-                NameStartsWithOrGreater = nameStartsWithOrGreater,
-                DtoOptions = dtoOptions,
-                SearchTerm = searchTerm,
-                EnableTotalRecordCount = enableTotalRecordCount,
-                OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
-            };
-
-            if (parentId.HasValue)
-            {
-                if (parentItem is Folder)
-                {
-                    query.AncestorIds = new[] { parentId.Value };
-                }
-                else
-                {
-                    query.ItemIds = new[] { parentId.Value };
-                }
-            }
+        var parentItem = _libraryManager.GetParentItem(parentId, userId);
 
-            QueryResult<(BaseItem, ItemCounts)> result;
-            if (parentItem is ICollectionFolder parentCollectionFolder
-                && (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal)
-                || string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal)))
+        var query = new InternalItemsQuery(user)
+        {
+            ExcludeItemTypes = excludeItemTypes,
+            IncludeItemTypes = includeItemTypes,
+            StartIndex = startIndex,
+            Limit = limit,
+            IsFavorite = isFavorite,
+            NameLessThan = nameLessThan,
+            NameStartsWith = nameStartsWith,
+            NameStartsWithOrGreater = nameStartsWithOrGreater,
+            DtoOptions = dtoOptions,
+            SearchTerm = searchTerm,
+            EnableTotalRecordCount = enableTotalRecordCount,
+            OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
+        };
+
+        if (parentId.HasValue)
+        {
+            if (parentItem is Folder)
             {
-                result = _libraryManager.GetMusicGenres(query);
+                query.AncestorIds = new[] { parentId.Value };
             }
             else
             {
-                result = _libraryManager.GetGenres(query);
+                query.ItemIds = new[] { parentId.Value };
             }
-
-            var shouldIncludeItemTypes = includeItemTypes.Length != 0;
-            return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
 
-        /// <summary>
-        /// Gets a genre, by name.
-        /// </summary>
-        /// <param name="genreName">The genre name.</param>
-        /// <param name="userId">The user id.</param>
-        /// <response code="200">Genres returned.</response>
-        /// <returns>An <see cref="OkResult"/> containing the genre.</returns>
-        [HttpGet("{genreName}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
+        QueryResult<(BaseItem, ItemCounts)> result;
+        if (parentItem is ICollectionFolder parentCollectionFolder
+            && (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal)
+                || string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal)))
         {
-            var dtoOptions = new DtoOptions()
-                .AddClientFields(User);
+            result = _libraryManager.GetMusicGenres(query);
+        }
+        else
+        {
+            result = _libraryManager.GetGenres(query);
+        }
 
-            Genre? item;
-            if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase))
-            {
-                item = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre);
-            }
-            else
-            {
-                item = _libraryManager.GetGenre(genreName);
-            }
+        var shouldIncludeItemTypes = includeItemTypes.Length != 0;
+        return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
+    }
 
-            item ??= new Genre();
+    /// <summary>
+    /// Gets a genre, by name.
+    /// </summary>
+    /// <param name="genreName">The genre name.</param>
+    /// <param name="userId">The user id.</param>
+    /// <response code="200">Genres returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the genre.</returns>
+    [HttpGet("{genreName}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
+    {
+        var dtoOptions = new DtoOptions()
+            .AddClientFields(User);
 
-            if (userId is null || userId.Value.Equals(default))
-            {
-                return _dtoService.GetBaseItemDto(item, dtoOptions);
-            }
+        Genre? item;
+        if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase))
+        {
+            item = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre);
+        }
+        else
+        {
+            item = _libraryManager.GetGenre(genreName);
+        }
 
-            var user = _userManager.GetUserById(userId.Value);
+        item ??= new Genre();
 
-            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+        if (userId is null || userId.Value.Equals(default))
+        {
+            return _dtoService.GetBaseItemDto(item, dtoOptions);
         }
 
-        private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind)
-            where T : BaseItem, new()
+        var user = _userManager.GetUserById(userId.Value);
+
+        return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+    }
+
+    private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind)
+        where T : BaseItem, new()
+    {
+        var result = libraryManager.GetItemList(new InternalItemsQuery
         {
-            var result = libraryManager.GetItemList(new InternalItemsQuery
-            {
-                Name = name.Replace(BaseItem.SlugChar, '&'),
-                IncludeItemTypes = new[] { baseItemKind },
-                DtoOptions = dtoOptions
-            }).OfType<T>().FirstOrDefault();
+            Name = name.Replace(BaseItem.SlugChar, '&'),
+            IncludeItemTypes = new[] { baseItemKind },
+            DtoOptions = dtoOptions
+        }).OfType<T>().FirstOrDefault();
 
-            result ??= libraryManager.GetItemList(new InternalItemsQuery
-            {
-                Name = name.Replace(BaseItem.SlugChar, '/'),
-                IncludeItemTypes = new[] { baseItemKind },
-                DtoOptions = dtoOptions
-            }).OfType<T>().FirstOrDefault();
+        result ??= libraryManager.GetItemList(new InternalItemsQuery
+        {
+            Name = name.Replace(BaseItem.SlugChar, '/'),
+            IncludeItemTypes = new[] { baseItemKind },
+            DtoOptions = dtoOptions
+        }).OfType<T>().FirstOrDefault();
 
-            result ??= libraryManager.GetItemList(new InternalItemsQuery
-            {
-                Name = name.Replace(BaseItem.SlugChar, '?'),
-                IncludeItemTypes = new[] { baseItemKind },
-                DtoOptions = dtoOptions
-            }).OfType<T>().FirstOrDefault();
+        result ??= libraryManager.GetItemList(new InternalItemsQuery
+        {
+            Name = name.Replace(BaseItem.SlugChar, '?'),
+            IncludeItemTypes = new[] { baseItemKind },
+            DtoOptions = dtoOptions
+        }).OfType<T>().FirstOrDefault();
 
-            return result;
-        }
+        return result;
     }
 }

+ 147 - 149
Jellyfin.Api/Controllers/HlsSegmentController.cs

@@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis;
 using System.IO;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Configuration;
@@ -15,178 +14,177 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The hls segment controller.
+/// </summary>
+[Route("")]
+public class HlsSegmentController : BaseJellyfinApiController
 {
+    private readonly IFileSystem _fileSystem;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly TranscodingJobHelper _transcodingJobHelper;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="HlsSegmentController"/> class.
+    /// </summary>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="transcodingJobHelper">Initialized instance of the <see cref="TranscodingJobHelper"/>.</param>
+    public HlsSegmentController(
+        IFileSystem fileSystem,
+        IServerConfigurationManager serverConfigurationManager,
+        TranscodingJobHelper transcodingJobHelper)
+    {
+        _fileSystem = fileSystem;
+        _serverConfigurationManager = serverConfigurationManager;
+        _transcodingJobHelper = transcodingJobHelper;
+    }
+
     /// <summary>
-    /// The hls segment controller.
+    /// Gets the specified audio segment for an audio item.
     /// </summary>
-    [Route("")]
-    public class HlsSegmentController : BaseJellyfinApiController
+    /// <param name="itemId">The item id.</param>
+    /// <param name="segmentId">The segment id.</param>
+    /// <response code="200">Hls audio segment returned.</response>
+    /// <returns>A <see cref="FileStreamResult"/> containing the audio stream.</returns>
+    // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
+    // [Authenticated]
+    [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.mp3", Name = "GetHlsAudioSegmentLegacyMp3")]
+    [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.aac", Name = "GetHlsAudioSegmentLegacyAac")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesAudioFile]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
+    public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
     {
-        private readonly IFileSystem _fileSystem;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly TranscodingJobHelper _transcodingJobHelper;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="HlsSegmentController"/> class.
-        /// </summary>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="transcodingJobHelper">Initialized instance of the <see cref="TranscodingJobHelper"/>.</param>
-        public HlsSegmentController(
-            IFileSystem fileSystem,
-            IServerConfigurationManager serverConfigurationManager,
-            TranscodingJobHelper transcodingJobHelper)
+        // TODO: Deprecate with new iOS app
+        var file = segmentId + Path.GetExtension(Request.Path);
+        var transcodePath = _serverConfigurationManager.GetTranscodePath();
+        file = Path.GetFullPath(Path.Combine(transcodePath, file));
+        var fileDir = Path.GetDirectoryName(file);
+        if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture))
         {
-            _fileSystem = fileSystem;
-            _serverConfigurationManager = serverConfigurationManager;
-            _transcodingJobHelper = transcodingJobHelper;
+            return BadRequest("Invalid segment.");
         }
 
-        /// <summary>
-        /// Gets the specified audio segment for an audio item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="segmentId">The segment id.</param>
-        /// <response code="200">Hls audio segment returned.</response>
-        /// <returns>A <see cref="FileStreamResult"/> containing the audio stream.</returns>
-        // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
-        // [Authenticated]
-        [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.mp3", Name = "GetHlsAudioSegmentLegacyMp3")]
-        [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.aac", Name = "GetHlsAudioSegmentLegacyAac")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesAudioFile]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
-        public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
-        {
-            // TODO: Deprecate with new iOS app
-            var file = segmentId + Path.GetExtension(Request.Path);
-            var transcodePath = _serverConfigurationManager.GetTranscodePath();
-            file = Path.GetFullPath(Path.Combine(transcodePath, file));
-            var fileDir = Path.GetDirectoryName(file);
-            if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture))
-            {
-                return BadRequest("Invalid segment.");
-            }
+        return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file));
+    }
 
-            return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file));
+    /// <summary>
+    /// Gets a hls video playlist.
+    /// </summary>
+    /// <param name="itemId">The video id.</param>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <response code="200">Hls video playlist returned.</response>
+    /// <returns>A <see cref="FileStreamResult"/> containing the playlist.</returns>
+    [HttpGet("Videos/{itemId}/hls/{playlistId}/stream.m3u8")]
+    [Authorize]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesPlaylistFile]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
+    public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
+    {
+        var file = playlistId + Path.GetExtension(Request.Path);
+        var transcodePath = _serverConfigurationManager.GetTranscodePath();
+        file = Path.GetFullPath(Path.Combine(transcodePath, file));
+        var fileDir = Path.GetDirectoryName(file);
+        if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) || Path.GetExtension(file) != ".m3u8")
+        {
+            return BadRequest("Invalid segment.");
         }
 
-        /// <summary>
-        /// Gets a hls video playlist.
-        /// </summary>
-        /// <param name="itemId">The video id.</param>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <response code="200">Hls video playlist returned.</response>
-        /// <returns>A <see cref="FileStreamResult"/> containing the playlist.</returns>
-        [HttpGet("Videos/{itemId}/hls/{playlistId}/stream.m3u8")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesPlaylistFile]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
-        public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
-        {
-            var file = playlistId + Path.GetExtension(Request.Path);
-            var transcodePath = _serverConfigurationManager.GetTranscodePath();
-            file = Path.GetFullPath(Path.Combine(transcodePath, file));
-            var fileDir = Path.GetDirectoryName(file);
-            if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) || Path.GetExtension(file) != ".m3u8")
-            {
-                return BadRequest("Invalid segment.");
-            }
+        return GetFileResult(file, file);
+    }
 
-            return GetFileResult(file, file);
-        }
+    /// <summary>
+    /// Stops an active encoding.
+    /// </summary>
+    /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <response code="204">Encoding stopped successfully.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpDelete("Videos/ActiveEncodings")]
+    [Authorize]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult StopEncodingProcess(
+        [FromQuery, Required] string deviceId,
+        [FromQuery, Required] string playSessionId)
+    {
+        _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Gets a hls video segment.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="segmentId">The segment id.</param>
+    /// <param name="segmentContainer">The segment container.</param>
+    /// <response code="200">Hls video segment returned.</response>
+    /// <response code="404">Hls segment not found.</response>
+    /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns>
+    // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
+    // [Authenticated]
+    [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesVideoFile]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
+    public ActionResult GetHlsVideoSegmentLegacy(
+        [FromRoute, Required] string itemId,
+        [FromRoute, Required] string playlistId,
+        [FromRoute, Required] string segmentId,
+        [FromRoute, Required] string segmentContainer)
+    {
+        var file = segmentId + Path.GetExtension(Request.Path);
+        var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
 
-        /// <summary>
-        /// Stops an active encoding.
-        /// </summary>
-        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
-        /// <param name="playSessionId">The play session id.</param>
-        /// <response code="204">Encoding stopped successfully.</response>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpDelete("Videos/ActiveEncodings")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult StopEncodingProcess(
-            [FromQuery, Required] string deviceId,
-            [FromQuery, Required] string playSessionId)
+        file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));
+        var fileDir = Path.GetDirectoryName(file);
+        if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture))
         {
-            _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true);
-            return NoContent();
+            return BadRequest("Invalid segment.");
         }
 
-        /// <summary>
-        /// Gets a hls video segment.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="playlistId">The playlist id.</param>
-        /// <param name="segmentId">The segment id.</param>
-        /// <param name="segmentContainer">The segment container.</param>
-        /// <response code="200">Hls video segment returned.</response>
-        /// <response code="404">Hls segment not found.</response>
-        /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns>
-        // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
-        // [Authenticated]
-        [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesVideoFile]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
-        public ActionResult GetHlsVideoSegmentLegacy(
-            [FromRoute, Required] string itemId,
-            [FromRoute, Required] string playlistId,
-            [FromRoute, Required] string segmentId,
-            [FromRoute, Required] string segmentContainer)
-        {
-            var file = segmentId + Path.GetExtension(Request.Path);
-            var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
+        var normalizedPlaylistId = playlistId;
 
-            file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));
-            var fileDir = Path.GetDirectoryName(file);
-            if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture))
+        var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
+        // Add . to start of segment container for future use.
+        segmentContainer = segmentContainer.Insert(0, ".");
+        string? playlistPath = null;
+        foreach (var path in filePaths)
+        {
+            var pathExtension = Path.GetExtension(path);
+            if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
+                 || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
+                && path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1)
             {
-                return BadRequest("Invalid segment.");
+                playlistPath = path;
+                break;
             }
+        }
 
-            var normalizedPlaylistId = playlistId;
-
-            var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
-            // Add . to start of segment container for future use.
-            segmentContainer = segmentContainer.Insert(0, ".");
-            string? playlistPath = null;
-            foreach (var path in filePaths)
-            {
-                var pathExtension = Path.GetExtension(path);
-                if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
-                     || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
-                    && path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1)
-                {
-                    playlistPath = path;
-                    break;
-                }
-            }
+        return playlistPath is null
+            ? NotFound("Hls segment not found.")
+            : GetFileResult(file, playlistPath);
+    }
 
-            return playlistPath is null
-                ? NotFound("Hls segment not found.")
-                : GetFileResult(file, playlistPath);
-        }
+    private ActionResult GetFileResult(string path, string playlistPath)
+    {
+        var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);
 
-        private ActionResult GetFileResult(string path, string playlistPath)
+        Response.OnCompleted(() =>
         {
-            var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);
-
-            Response.OnCompleted(() =>
+            if (transcodingJob is not null)
             {
-                if (transcodingJob is not null)
-                {
-                    _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob);
-                }
+                _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob);
+            }
 
-                return Task.CompletedTask;
-            });
+            return Task.CompletedTask;
+        });
 
-            return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path));
-        }
+        return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path));
     }
 }

+ 1874 - 1910
Jellyfin.Api/Controllers/ImageController.cs

@@ -30,2116 +30,2080 @@ using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 using Microsoft.Net.Http.Headers;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Image controller.
+/// </summary>
+[Route("")]
+public class ImageController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IProviderManager _providerManager;
+    private readonly IImageProcessor _imageProcessor;
+    private readonly IFileSystem _fileSystem;
+    private readonly ILogger<ImageController> _logger;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+    private readonly IApplicationPaths _appPaths;
+
     /// <summary>
-    /// Image controller.
+    /// Initializes a new instance of the <see cref="ImageController"/> class.
     /// </summary>
-    [Route("")]
-    public class ImageController : BaseJellyfinApiController
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+    /// <param name="imageProcessor">Instance of the <see cref="IImageProcessor"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{ImageController}"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+    public ImageController(
+        IUserManager userManager,
+        ILibraryManager libraryManager,
+        IProviderManager providerManager,
+        IImageProcessor imageProcessor,
+        IFileSystem fileSystem,
+        ILogger<ImageController> logger,
+        IServerConfigurationManager serverConfigurationManager,
+        IApplicationPaths appPaths)
     {
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IProviderManager _providerManager;
-        private readonly IImageProcessor _imageProcessor;
-        private readonly IFileSystem _fileSystem;
-        private readonly ILogger<ImageController> _logger;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-        private readonly IApplicationPaths _appPaths;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ImageController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
-        /// <param name="imageProcessor">Instance of the <see cref="IImageProcessor"/> interface.</param>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{ImageController}"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
-        public ImageController(
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IProviderManager providerManager,
-            IImageProcessor imageProcessor,
-            IFileSystem fileSystem,
-            ILogger<ImageController> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IApplicationPaths appPaths)
-        {
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _providerManager = providerManager;
-            _imageProcessor = imageProcessor;
-            _fileSystem = fileSystem;
-            _logger = logger;
-            _serverConfigurationManager = serverConfigurationManager;
-            _appPaths = appPaths;
-        }
-
-        /// <summary>
-        /// Sets the user image.
-        /// </summary>
-        /// <param name="userId">User Id.</param>
-        /// <param name="imageType">(Unused) Image type.</param>
-        /// <param name="index">(Unused) Image index.</param>
-        /// <response code="204">Image updated.</response>
-        /// <response code="403">User does not have permission to delete the image.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Users/{userId}/Images/{imageType}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [AcceptsImageFile]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> PostUserImage(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] int? index = null)
-        {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
-            {
-                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
-            }
-
-            var user = _userManager.GetUserById(userId);
-            var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-            await using (memoryStream.ConfigureAwait(false))
-            {
-                // Handle image/png; charset=utf-8
-                var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
-                var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
-                if (user.ProfileImage is not null)
-                {
-                    await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
-                }
-
-                user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
+        _userManager = userManager;
+        _libraryManager = libraryManager;
+        _providerManager = providerManager;
+        _imageProcessor = imageProcessor;
+        _fileSystem = fileSystem;
+        _logger = logger;
+        _serverConfigurationManager = serverConfigurationManager;
+        _appPaths = appPaths;
+    }
 
-                await _providerManager
-                    .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
-                    .ConfigureAwait(false);
-                await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+    /// <summary>
+    /// Sets the user image.
+    /// </summary>
+    /// <param name="userId">User Id.</param>
+    /// <param name="imageType">(Unused) Image type.</param>
+    /// <param name="index">(Unused) Image index.</param>
+    /// <response code="204">Image updated.</response>
+    /// <response code="403">User does not have permission to delete the image.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Users/{userId}/Images/{imageType}")]
+    [Authorize]
+    [AcceptsImageFile]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> PostUserImage(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] int? index = null)
+    {
+        var user = _userManager.GetUserById(userId);
+        if (user is null)
+        {
+            return NotFound();
+        }
 
-                return NoContent();
-            }
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
         }
 
-        /// <summary>
-        /// Sets the user image.
-        /// </summary>
-        /// <param name="userId">User Id.</param>
-        /// <param name="imageType">(Unused) Image type.</param>
-        /// <param name="index">(Unused) Image index.</param>
-        /// <response code="204">Image updated.</response>
-        /// <response code="403">User does not have permission to delete the image.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpPost("Users/{userId}/Images/{imageType}/{index}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [AcceptsImageFile]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> PostUserImageByIndex(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute] int index)
-        {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+        await using (memoryStream.ConfigureAwait(false))
+        {
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+            var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+            if (user.ProfileImage is not null)
             {
-                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
+                await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
             }
 
-            var user = _userManager.GetUserById(userId);
-            var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-            await using (memoryStream.ConfigureAwait(false))
-            {
-                // Handle image/png; charset=utf-8
-                var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
-                var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
-                if (user.ProfileImage is not null)
-                {
-                    await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
-                }
+            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
 
-                user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
+            await _providerManager
+                .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+                .ConfigureAwait(false);
+            await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
 
-                await _providerManager
-                    .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
-                    .ConfigureAwait(false);
-                await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+            return NoContent();
+        }
+    }
 
-                return NoContent();
-            }
+    /// <summary>
+    /// Sets the user image.
+    /// </summary>
+    /// <param name="userId">User Id.</param>
+    /// <param name="imageType">(Unused) Image type.</param>
+    /// <param name="index">(Unused) Image index.</param>
+    /// <response code="204">Image updated.</response>
+    /// <response code="403">User does not have permission to delete the image.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Users/{userId}/Images/{imageType}/{index}")]
+    [Authorize]
+    [AcceptsImageFile]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> PostUserImageByIndex(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute] int index)
+    {
+        var user = _userManager.GetUserById(userId);
+        if (user is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Delete the user's image.
-        /// </summary>
-        /// <param name="userId">User Id.</param>
-        /// <param name="imageType">(Unused) Image type.</param>
-        /// <param name="index">(Unused) Image index.</param>
-        /// <response code="204">Image deleted.</response>
-        /// <response code="403">User does not have permission to delete the image.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Users/{userId}/Images/{imageType}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        public async Task<ActionResult> DeleteUserImage(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] int? index = null)
-        {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
-            {
-                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
-            }
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
+        }
 
-            var user = _userManager.GetUserById(userId);
-            if (user?.ProfileImage is null)
+        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+        await using (memoryStream.ConfigureAwait(false))
+        {
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+            var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+            if (user.ProfileImage is not null)
             {
-                return NoContent();
+                await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
             }
 
-            try
-            {
-                System.IO.File.Delete(user.ProfileImage.Path);
-            }
-            catch (IOException e)
-            {
-                _logger.LogError(e, "Error deleting user profile image:");
-            }
+            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
+
+            await _providerManager
+                .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+                .ConfigureAwait(false);
+            await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
 
-            await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
             return NoContent();
         }
+    }
 
-        /// <summary>
-        /// Delete the user's image.
-        /// </summary>
-        /// <param name="userId">User Id.</param>
-        /// <param name="imageType">(Unused) Image type.</param>
-        /// <param name="index">(Unused) Image index.</param>
-        /// <response code="204">Image deleted.</response>
-        /// <response code="403">User does not have permission to delete the image.</response>
-        /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Users/{userId}/Images/{imageType}/{index}")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        public async Task<ActionResult> DeleteUserImageByIndex(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute] int index)
-        {
-            if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
-            {
-                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
-            }
-
-            var user = _userManager.GetUserById(userId);
-            if (user?.ProfileImage is null)
-            {
-                return NoContent();
-            }
-
-            try
-            {
-                System.IO.File.Delete(user.ProfileImage.Path);
-            }
-            catch (IOException e)
-            {
-                _logger.LogError(e, "Error deleting user profile image:");
-            }
+    /// <summary>
+    /// Delete the user's image.
+    /// </summary>
+    /// <param name="userId">User Id.</param>
+    /// <param name="imageType">(Unused) Image type.</param>
+    /// <param name="index">(Unused) Image index.</param>
+    /// <response code="204">Image deleted.</response>
+    /// <response code="403">User does not have permission to delete the image.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Users/{userId}/Images/{imageType}")]
+    [Authorize]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public async Task<ActionResult> DeleteUserImage(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] int? index = null)
+    {
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
+        }
 
-            await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+        var user = _userManager.GetUserById(userId);
+        if (user?.ProfileImage is null)
+        {
             return NoContent();
         }
 
-        /// <summary>
-        /// Delete an item's image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">The image index.</param>
-        /// <response code="204">Image deleted.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpDelete("Items/{itemId}/Images/{imageType}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> DeleteItemImage(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        try
+        {
+            System.IO.File.Delete(user.ProfileImage.Path);
+        }
+        catch (IOException e)
+        {
+            _logger.LogError(e, "Error deleting user profile image:");
+        }
+
+        await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            await item.DeleteImageAsync(imageType, imageIndex ?? 0).ConfigureAwait(false);
+    /// <summary>
+    /// Delete the user's image.
+    /// </summary>
+    /// <param name="userId">User Id.</param>
+    /// <param name="imageType">(Unused) Image type.</param>
+    /// <param name="index">(Unused) Image index.</param>
+    /// <response code="204">Image deleted.</response>
+    /// <response code="403">User does not have permission to delete the image.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Users/{userId}/Images/{imageType}/{index}")]
+    [Authorize]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public async Task<ActionResult> DeleteUserImageByIndex(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute] int index)
+    {
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        {
+            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
+        }
+
+        var user = _userManager.GetUserById(userId);
+        if (user?.ProfileImage is null)
+        {
             return NoContent();
         }
 
-        /// <summary>
-        /// Delete an item's image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">The image index.</param>
-        /// <response code="204">Image deleted.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> DeleteItemImageByIndex(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute] int imageIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        try
+        {
+            System.IO.File.Delete(user.ProfileImage.Path);
+        }
+        catch (IOException e)
+        {
+            _logger.LogError(e, "Error deleting user profile image:");
+        }
 
-            await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false);
-            return NoContent();
+        await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Delete an item's image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">The image index.</param>
+    /// <response code="204">Image deleted.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpDelete("Items/{itemId}/Images/{imageType}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> DeleteItemImage(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Set item image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <response code="204">Image saved.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpPost("Items/{itemId}/Images/{imageType}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [AcceptsImageFile]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> SetItemImage(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        await item.DeleteImageAsync(imageType, imageIndex ?? 0).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-            await using (memoryStream.ConfigureAwait(false))
-            {
-                // Handle image/png; charset=utf-8
-                var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
-                await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
-                await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+    /// <summary>
+    /// Delete an item's image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">The image index.</param>
+    /// <response code="204">Image deleted.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> DeleteItemImageByIndex(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute] int imageIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
+        }
 
-                return NoContent();
-            }
+        await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Set item image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <response code="204">Image saved.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpPost("Items/{itemId}/Images/{imageType}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [AcceptsImageFile]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> SetItemImage(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Set item image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">(Unused) Image index.</param>
-        /// <response code="204">Image saved.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [AcceptsImageFile]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-        public async Task<ActionResult> SetItemImageByIndex(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute] int imageIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+        await using (memoryStream.ConfigureAwait(false))
+        {
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+            await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+            await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
 
-            var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-            await using (memoryStream.ConfigureAwait(false))
-            {
-                // Handle image/png; charset=utf-8
-                var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
-                await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
-                await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+            return NoContent();
+        }
+    }
 
-                return NoContent();
-            }
+    /// <summary>
+    /// Set item image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">(Unused) Image index.</param>
+    /// <response code="204">Image saved.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [AcceptsImageFile]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    public async Task<ActionResult> SetItemImageByIndex(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute] int imageIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Updates the index for an item image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Old image index.</param>
-        /// <param name="newIndex">New image index.</param>
-        /// <response code="204">Image index updated.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}/Index")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> UpdateItemImageIndex(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery, Required] int newIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+        await using (memoryStream.ConfigureAwait(false))
+        {
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
+            await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+            await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
 
-            await item.SwapImagesAsync(imageType, imageIndex, newIndex).ConfigureAwait(false);
             return NoContent();
         }
+    }
 
-        /// <summary>
-        /// Get item image infos.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">Item images returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>The list of image infos on success, or <see cref="NotFoundResult"/> if item not found.</returns>
-        [HttpGet("Items/{itemId}/Images")]
-        [Authorize(Policy = Policies.DefaultAuthorization)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+    /// <summary>
+    /// Updates the index for an item image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Old image index.</param>
+    /// <param name="newIndex">New image index.</param>
+    /// <response code="204">Image index updated.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}/Index")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> UpdateItemImageIndex(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery, Required] int newIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
+        }
 
-            var list = new List<ImageInfo>();
-            var itemImages = item.ImageInfos;
+        await item.SwapImagesAsync(imageType, imageIndex, newIndex).ConfigureAwait(false);
+        return NoContent();
+    }
 
-            if (itemImages.Length == 0)
-            {
-                // short-circuit
-                return list;
-            }
+    /// <summary>
+    /// Get item image infos.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Item images returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>The list of image infos on success, or <see cref="NotFoundResult"/> if item not found.</returns>
+    [HttpGet("Items/{itemId}/Images")]
+    [Authorize]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
+        }
 
-            await _libraryManager.UpdateImagesAsync(item).ConfigureAwait(false); // this makes sure dimensions and hashes are correct
+        var list = new List<ImageInfo>();
+        var itemImages = item.ImageInfos;
 
-            foreach (var image in itemImages)
+        if (itemImages.Length == 0)
+        {
+            // short-circuit
+            return list;
+        }
+
+        await _libraryManager.UpdateImagesAsync(item).ConfigureAwait(false); // this makes sure dimensions and hashes are correct
+
+        foreach (var image in itemImages)
+        {
+            if (!item.AllowsMultipleImages(image.Type))
             {
-                if (!item.AllowsMultipleImages(image.Type))
-                {
-                    var info = GetImageInfo(item, image, null);
+                var info = GetImageInfo(item, image, null);
 
-                    if (info is not null)
-                    {
-                        list.Add(info);
-                    }
+                if (info is not null)
+                {
+                    list.Add(info);
                 }
             }
+        }
 
-            foreach (var imageType in itemImages.Select(i => i.Type).Distinct().Where(item.AllowsMultipleImages))
-            {
-                var index = 0;
-
-                // Prevent implicitly captured closure
-                var currentImageType = imageType;
+        foreach (var imageType in itemImages.Select(i => i.Type).Distinct().Where(item.AllowsMultipleImages))
+        {
+            var index = 0;
 
-                foreach (var image in itemImages.Where(i => i.Type == currentImageType))
-                {
-                    var info = GetImageInfo(item, image, index);
+            // Prevent implicitly captured closure
+            var currentImageType = imageType;
 
-                    if (info is not null)
-                    {
-                        list.Add(info);
-                    }
+            foreach (var image in itemImages.Where(i => i.Type == currentImageType))
+            {
+                var info = GetImageInfo(item, image, index);
 
-                    index++;
+                if (info is not null)
+                {
+                    list.Add(info);
                 }
+
+                index++;
             }
+        }
 
-            return list;
+        return list;
+    }
+
+    /// <summary>
+    /// Gets the item's image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Items/{itemId}/Images/{imageType}")]
+    [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetItemImage(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery] string? tag,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets the item's image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Items/{itemId}/Images/{imageType}")]
-        [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetItemImage(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery] string? tag,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                itemId,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    itemId,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Gets the item's image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetItemImageByIndex(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute] int imageIndex,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery] string? tag,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets the item's image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetItemImageByIndex(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute] int imageIndex,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery] string? tag,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                itemId,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    itemId,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Gets the item's image.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")]
+    [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}", Name = "HeadItemImage2")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetItemImage2(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int maxWidth,
+        [FromRoute, Required] int maxHeight,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromRoute, Required] string tag,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromRoute, Required] ImageFormat format,
+        [FromRoute, Required] double percentPlayed,
+        [FromRoute, Required] int unplayedCount,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromRoute, Required] int imageIndex)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Gets the item's image.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")]
-        [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}", Name = "HeadItemImage2")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetItemImage2(
-            [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int maxWidth,
-            [FromRoute, Required] int maxHeight,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromRoute, Required] string tag,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromRoute, Required] ImageFormat format,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromRoute, Required] double percentPlayed,
-            [FromRoute, Required] int unplayedCount,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromRoute, Required] int imageIndex)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                itemId,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    itemId,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get artist image by name.
+    /// </summary>
+    /// <param name="name">Artist name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetArtistImage(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromRoute, Required] int imageIndex)
+    {
+        var item = _libraryManager.GetArtist(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get artist image by name.
-        /// </summary>
-        /// <param name="name">Artist name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetArtistImage(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromRoute, Required] int imageIndex)
-        {
-            var item = _libraryManager.GetArtist(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get genre image by name.
+    /// </summary>
+    /// <param name="name">Genre name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Genres/{name}/Images/{imageType}")]
+    [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetGenreImage(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetGenre(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get genre image by name.
-        /// </summary>
-        /// <param name="name">Genre name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Genres/{name}/Images/{imageType}")]
-        [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetGenreImage(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetGenre(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get genre image by name.
+    /// </summary>
+    /// <param name="name">Genre name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetGenreImageByIndex(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var item = _libraryManager.GetGenre(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get genre image by name.
-        /// </summary>
-        /// <param name="name">Genre name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetGenreImageByIndex(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var item = _libraryManager.GetGenre(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get music genre image by name.
+    /// </summary>
+    /// <param name="name">Music genre name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("MusicGenres/{name}/Images/{imageType}")]
+    [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetMusicGenreImage(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetMusicGenre(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get music genre image by name.
-        /// </summary>
-        /// <param name="name">Music genre name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("MusicGenres/{name}/Images/{imageType}")]
-        [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetMusicGenreImage(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetMusicGenre(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get music genre image by name.
+    /// </summary>
+    /// <param name="name">Music genre name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetMusicGenreImageByIndex(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var item = _libraryManager.GetMusicGenre(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get music genre image by name.
-        /// </summary>
-        /// <param name="name">Music genre name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetMusicGenreImageByIndex(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var item = _libraryManager.GetMusicGenre(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get person image by name.
+    /// </summary>
+    /// <param name="name">Person name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Persons/{name}/Images/{imageType}")]
+    [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetPersonImage(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetPerson(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get person image by name.
-        /// </summary>
-        /// <param name="name">Person name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Persons/{name}/Images/{imageType}")]
-        [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetPersonImage(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetPerson(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get person image by name.
+    /// </summary>
+    /// <param name="name">Person name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetPersonImageByIndex(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var item = _libraryManager.GetPerson(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get person image by name.
-        /// </summary>
-        /// <param name="name">Person name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetPersonImageByIndex(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var item = _libraryManager.GetPerson(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get studio image by name.
+    /// </summary>
+    /// <param name="name">Studio name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Studios/{name}/Images/{imageType}")]
+    [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetStudioImage(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var item = _libraryManager.GetStudio(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get studio image by name.
-        /// </summary>
-        /// <param name="name">Studio name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Studios/{name}/Images/{imageType}")]
-        [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetStudioImage(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var item = _libraryManager.GetStudio(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get studio image by name.
+    /// </summary>
+    /// <param name="name">Studio name.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetStudioImageByIndex(
+        [FromRoute, Required] string name,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var item = _libraryManager.GetStudio(name);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get studio image by name.
-        /// </summary>
-        /// <param name="name">Studio name.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetStudioImageByIndex(
-            [FromRoute, Required] string name,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var item = _libraryManager.GetStudio(name);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return await GetImageInternal(
+                item.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                item)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    item.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    item)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get user profile image.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Users/{userId}/Images/{imageType}")]
+    [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetUserImage(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+    {
+        var user = _userManager.GetUserById(userId);
+        if (user?.ProfileImage is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get user profile image.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Users/{userId}/Images/{imageType}")]
-        [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetUserImage(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery] int? imageIndex)
-        {
-            var user = _userManager.GetUserById(userId);
-            if (user?.ProfileImage is null)
-            {
-                return NotFound();
-            }
+        var info = new ItemImageInfo
+        {
+            Path = user.ProfileImage.Path,
+            Type = ImageType.Profile,
+            DateModified = user.ProfileImage.LastModified
+        };
 
-            var info = new ItemImageInfo
-            {
-                Path = user.ProfileImage.Path,
-                Type = ImageType.Profile,
-                DateModified = user.ProfileImage.LastModified
-            };
+        if (width.HasValue)
+        {
+            info.Width = width.Value;
+        }
 
-            if (width.HasValue)
-            {
-                info.Width = width.Value;
-            }
+        if (height.HasValue)
+        {
+            info.Height = height.Value;
+        }
 
-            if (height.HasValue)
-            {
-                info.Height = height.Value;
-            }
+        return await GetImageInternal(
+                user.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                null,
+                info)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    user.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    null,
-                    info)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Get user profile image.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")]
+    [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetUserImageByIndex(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromRoute, Required] int imageIndex,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery, ParameterObsolete] bool? cropWhitespace,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer)
+    {
+        var user = _userManager.GetUserById(userId);
+        if (user?.ProfileImage is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get user profile image.
-        /// </summary>
-        /// <param name="userId">User id.</param>
-        /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">Image index.</param>
-        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
-        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
-        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
-        /// <param name="blur">Optional. Blur image.</param>
-        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <response code="200">Image stream returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>
-        /// A <see cref="FileStreamResult"/> containing the file stream on success,
-        /// or a <see cref="NotFoundResult"/> if item not found.
-        /// </returns>
-        [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")]
-        [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetUserImageByIndex(
-            [FromRoute, Required] Guid userId,
-            [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] int imageIndex,
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] double? percentPlayed,
-            [FromQuery] int? unplayedCount,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? quality,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery, ParameterObsolete] bool? cropWhitespace,
-            [FromQuery] bool? addPlayedIndicator,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer)
-        {
-            var user = _userManager.GetUserById(userId);
-            if (user?.ProfileImage is null)
-            {
-                return NotFound();
-            }
+        var info = new ItemImageInfo
+        {
+            Path = user.ProfileImage.Path,
+            Type = ImageType.Profile,
+            DateModified = user.ProfileImage.LastModified
+        };
 
-            var info = new ItemImageInfo
-            {
-                Path = user.ProfileImage.Path,
-                Type = ImageType.Profile,
-                DateModified = user.ProfileImage.LastModified
-            };
+        if (width.HasValue)
+        {
+            info.Width = width.Value;
+        }
 
-            if (width.HasValue)
-            {
-                info.Width = width.Value;
-            }
+        if (height.HasValue)
+        {
+            info.Height = height.Value;
+        }
 
-            if (height.HasValue)
-            {
-                info.Height = height.Value;
-            }
+        return await GetImageInternal(
+                user.Id,
+                imageType,
+                imageIndex,
+                tag,
+                format,
+                maxWidth,
+                maxHeight,
+                percentPlayed,
+                unplayedCount,
+                width,
+                height,
+                quality,
+                fillWidth,
+                fillHeight,
+                blur,
+                backgroundColor,
+                foregroundLayer,
+                null,
+                info)
+            .ConfigureAwait(false);
+    }
 
-            return await GetImageInternal(
-                    user.Id,
-                    imageType,
-                    imageIndex,
-                    tag,
-                    format,
-                    maxWidth,
-                    maxHeight,
-                    percentPlayed,
-                    unplayedCount,
-                    width,
-                    height,
-                    quality,
-                    fillWidth,
-                    fillHeight,
-                    addPlayedIndicator,
-                    blur,
-                    backgroundColor,
-                    foregroundLayer,
-                    null,
-                    info)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Generates or gets the splashscreen.
+    /// </summary>
+    /// <param name="tag">Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="blur">Blur image.</param>
+    /// <param name="backgroundColor">Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Apply a foreground layer on top of the image.</param>
+    /// <param name="quality">Quality setting, from 0-100.</param>
+    /// <response code="200">Splashscreen returned successfully.</response>
+    /// <returns>The splashscreen.</returns>
+    [HttpGet("Branding/Splashscreen")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesImageFile]
+    public async Task<ActionResult> GetSplashscreen(
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery, Range(0, 100)] int quality = 90)
+    {
+        var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+        if (!brandingOptions.SplashscreenEnabled)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Generates or gets the splashscreen.
-        /// </summary>
-        /// <param name="tag">Supply the cache tag from the item object to receive strong caching headers.</param>
-        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
-        /// <param name="maxWidth">The maximum image width to return.</param>
-        /// <param name="maxHeight">The maximum image height to return.</param>
-        /// <param name="width">The fixed image width to return.</param>
-        /// <param name="height">The fixed image height to return.</param>
-        /// <param name="fillWidth">Width of box to fill.</param>
-        /// <param name="fillHeight">Height of box to fill.</param>
-        /// <param name="blur">Blur image.</param>
-        /// <param name="backgroundColor">Apply a background color for transparent images.</param>
-        /// <param name="foregroundLayer">Apply a foreground layer on top of the image.</param>
-        /// <param name="quality">Quality setting, from 0-100.</param>
-        /// <response code="200">Splashscreen returned successfully.</response>
-        /// <returns>The splashscreen.</returns>
-        [HttpGet("Branding/Splashscreen")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesImageFile]
-        public async Task<ActionResult> GetSplashscreen(
-            [FromQuery] string? tag,
-            [FromQuery] ImageFormat? format,
-            [FromQuery] int? maxWidth,
-            [FromQuery] int? maxHeight,
-            [FromQuery] int? width,
-            [FromQuery] int? height,
-            [FromQuery] int? fillWidth,
-            [FromQuery] int? fillHeight,
-            [FromQuery] int? blur,
-            [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromQuery, Range(0, 100)] int quality = 90)
+        string splashscreenPath;
+
+        if (!string.IsNullOrWhiteSpace(brandingOptions.SplashscreenLocation)
+            && System.IO.File.Exists(brandingOptions.SplashscreenLocation))
         {
-            var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
-            if (!brandingOptions.SplashscreenEnabled)
+            splashscreenPath = brandingOptions.SplashscreenLocation;
+        }
+        else
+        {
+            splashscreenPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
+            if (!System.IO.File.Exists(splashscreenPath))
             {
                 return NotFound();
             }
+        }
 
-            string splashscreenPath;
+        var outputFormats = GetOutputFormats(format);
 
-            if (!string.IsNullOrWhiteSpace(brandingOptions.SplashscreenLocation)
-                && System.IO.File.Exists(brandingOptions.SplashscreenLocation))
+        TimeSpan? cacheDuration = null;
+        if (!string.IsNullOrEmpty(tag))
+        {
+            cacheDuration = TimeSpan.FromDays(365);
+        }
+
+        var options = new ImageProcessingOptions
+        {
+            Image = new ItemImageInfo
+            {
+                Path = splashscreenPath
+            },
+            Height = height,
+            MaxHeight = maxHeight,
+            MaxWidth = maxWidth,
+            FillHeight = fillHeight,
+            FillWidth = fillWidth,
+            Quality = quality,
+            Width = width,
+            Blur = blur,
+            BackgroundColor = backgroundColor,
+            ForegroundLayer = foregroundLayer,
+            SupportedOutputFormats = outputFormats
+        };
+
+        return await GetImageResult(
+                options,
+                cacheDuration,
+                ImmutableDictionary<string, string>.Empty)
+            .ConfigureAwait(false);
+    }
+
+    /// <summary>
+    /// Uploads a custom splashscreen.
+    /// The body is expected to the image contents base64 encoded.
+    /// </summary>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    /// <response code="204">Successfully uploaded new splashscreen.</response>
+    /// <response code="400">Error reading MimeType from uploaded image.</response>
+    /// <response code="403">User does not have permission to upload splashscreen..</response>
+    /// <exception cref="ArgumentException">Error reading the image format.</exception>
+    [HttpPost("Branding/Splashscreen")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [AcceptsImageFile]
+    public async Task<ActionResult> UploadCustomSplashscreen()
+    {
+        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+        await using (memoryStream.ConfigureAwait(false))
+        {
+            var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType;
+
+            if (!mimeType.HasValue)
             {
-                splashscreenPath = brandingOptions.SplashscreenLocation;
+                return BadRequest("Error reading mimetype from uploaded image");
             }
-            else
+
+            var extension = MimeTypes.ToExtension(mimeType.Value);
+            if (string.IsNullOrEmpty(extension))
             {
-                splashscreenPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
-                if (!System.IO.File.Exists(splashscreenPath))
-                {
-                    return NotFound();
-                }
+                return BadRequest("Error converting mimetype to an image extension");
             }
 
-            var outputFormats = GetOutputFormats(format);
+            var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension);
+            var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+            brandingOptions.SplashscreenLocation = filePath;
+            _serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
 
-            TimeSpan? cacheDuration = null;
-            if (!string.IsNullOrEmpty(tag))
+            var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
+            await using (fs.ConfigureAwait(false))
             {
-                cacheDuration = TimeSpan.FromDays(365);
+                await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false);
             }
 
-            var options = new ImageProcessingOptions
-            {
-                Image = new ItemImageInfo
-                {
-                    Path = splashscreenPath
-                },
-                Height = height,
-                MaxHeight = maxHeight,
-                MaxWidth = maxWidth,
-                FillHeight = fillHeight,
-                FillWidth = fillWidth,
-                Quality = quality,
-                Width = width,
-                Blur = blur,
-                BackgroundColor = backgroundColor,
-                ForegroundLayer = foregroundLayer,
-                SupportedOutputFormats = outputFormats
-            };
+            return NoContent();
+        }
+    }
 
-            return await GetImageResult(
-                    options,
-                    cacheDuration,
-                    ImmutableDictionary<string, string>.Empty)
-                .ConfigureAwait(false);
+    /// <summary>
+    /// Delete a custom splashscreen.
+    /// </summary>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    /// <response code="204">Successfully deleted the custom splashscreen.</response>
+    /// <response code="403">User does not have permission to delete splashscreen..</response>
+    [HttpDelete("Branding/Splashscreen")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public ActionResult DeleteCustomSplashscreen()
+    {
+        var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+        if (!string.IsNullOrEmpty(brandingOptions.SplashscreenLocation)
+            && System.IO.File.Exists(brandingOptions.SplashscreenLocation))
+        {
+            System.IO.File.Delete(brandingOptions.SplashscreenLocation);
+            brandingOptions.SplashscreenLocation = null;
+            _serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
         }
 
-        /// <summary>
-        /// Uploads a custom splashscreen.
-        /// The body is expected to the image contents base64 encoded.
-        /// </summary>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        /// <response code="204">Successfully uploaded new splashscreen.</response>
-        /// <response code="400">Error reading MimeType from uploaded image.</response>
-        /// <response code="403">User does not have permission to upload splashscreen..</response>
-        /// <exception cref="ArgumentException">Error reading the image format.</exception>
-        [HttpPost("Branding/Splashscreen")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status400BadRequest)]
-        [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        [AcceptsImageFile]
-        public async Task<ActionResult> UploadCustomSplashscreen()
-        {
-            var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-            await using (memoryStream.ConfigureAwait(false))
-            {
-                var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType;
+        return NoContent();
+    }
 
-                if (!mimeType.HasValue)
-                {
-                    return BadRequest("Error reading mimetype from uploaded image");
-                }
+    private static async Task<MemoryStream> GetMemoryStream(Stream inputStream)
+    {
+        using var reader = new StreamReader(inputStream);
+        var text = await reader.ReadToEndAsync().ConfigureAwait(false);
 
-                var extension = MimeTypes.ToExtension(mimeType.Value);
-                if (string.IsNullOrEmpty(extension))
-                {
-                    return BadRequest("Error converting mimetype to an image extension");
-                }
+        var bytes = Convert.FromBase64String(text);
+        return new MemoryStream(bytes, 0, bytes.Length, false, true);
+    }
 
-                var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension);
-                var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
-                brandingOptions.SplashscreenLocation = filePath;
-                _serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
+    private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex)
+    {
+        int? width = null;
+        int? height = null;
+        string? blurhash = null;
+        long length = 0;
+
+        try
+        {
+            if (info.IsLocalFile)
+            {
+                var fileInfo = _fileSystem.GetFileInfo(info.Path);
+                length = fileInfo.Length;
 
-                var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
-                await using (fs.ConfigureAwait(false))
+                blurhash = info.BlurHash;
+                width = info.Width;
+                height = info.Height;
+
+                if (width <= 0 || height <= 0)
                 {
-                    await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false);
+                    width = null;
+                    height = null;
                 }
-
-                return NoContent();
             }
         }
-
-        /// <summary>
-        /// Delete a custom splashscreen.
-        /// </summary>
-        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        /// <response code="204">Successfully deleted the custom splashscreen.</response>
-        /// <response code="403">User does not have permission to delete splashscreen..</response>
-        [HttpDelete("Branding/Splashscreen")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult DeleteCustomSplashscreen()
+        catch (Exception ex)
         {
-            var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
-            if (!string.IsNullOrEmpty(brandingOptions.SplashscreenLocation)
-                && System.IO.File.Exists(brandingOptions.SplashscreenLocation))
-            {
-                System.IO.File.Delete(brandingOptions.SplashscreenLocation);
-                brandingOptions.SplashscreenLocation = null;
-                _serverConfigurationManager.SaveConfiguration("branding", brandingOptions);
-            }
-
-            return NoContent();
+            _logger.LogError(ex, "Error getting image information for {Item}", item.Name);
         }
 
-        private static async Task<MemoryStream> GetMemoryStream(Stream inputStream)
+        try
         {
-            using var reader = new StreamReader(inputStream);
-            var text = await reader.ReadToEndAsync().ConfigureAwait(false);
-
-            var bytes = Convert.FromBase64String(text);
-            return new MemoryStream(bytes, 0, bytes.Length, false, true);
+            return new ImageInfo
+            {
+                Path = info.Path,
+                ImageIndex = imageIndex,
+                ImageType = info.Type,
+                ImageTag = _imageProcessor.GetImageCacheTag(item, info),
+                Size = length,
+                BlurHash = blurhash,
+                Width = width,
+                Height = height
+            };
         }
-
-        private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex)
+        catch (Exception ex)
         {
-            int? width = null;
-            int? height = null;
-            string? blurhash = null;
-            long length = 0;
-
-            try
-            {
-                if (info.IsLocalFile)
-                {
-                    var fileInfo = _fileSystem.GetFileInfo(info.Path);
-                    length = fileInfo.Length;
-
-                    blurhash = info.BlurHash;
-                    width = info.Width;
-                    height = info.Height;
-
-                    if (width <= 0 || height <= 0)
-                    {
-                        width = null;
-                        height = null;
-                    }
-                }
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error getting image information for {Item}", item.Name);
-            }
+            _logger.LogError(ex, "Error getting image information for {Path}", info.Path);
+            return null;
+        }
+    }
 
-            try
+    private async Task<ActionResult> GetImageInternal(
+        Guid itemId,
+        ImageType imageType,
+        int? imageIndex,
+        string? tag,
+        ImageFormat? format,
+        int? maxWidth,
+        int? maxHeight,
+        double? percentPlayed,
+        int? unplayedCount,
+        int? width,
+        int? height,
+        int? quality,
+        int? fillWidth,
+        int? fillHeight,
+        int? blur,
+        string? backgroundColor,
+        string? foregroundLayer,
+        BaseItem? item,
+        ItemImageInfo? imageInfo = null)
+    {
+        if (percentPlayed.HasValue)
+        {
+            if (percentPlayed.Value <= 0)
             {
-                return new ImageInfo
-                {
-                    Path = info.Path,
-                    ImageIndex = imageIndex,
-                    ImageType = info.Type,
-                    ImageTag = _imageProcessor.GetImageCacheTag(item, info),
-                    Size = length,
-                    BlurHash = blurhash,
-                    Width = width,
-                    Height = height
-                };
+                percentPlayed = null;
             }
-            catch (Exception ex)
+            else if (percentPlayed.Value >= 100)
             {
-                _logger.LogError(ex, "Error getting image information for {Path}", info.Path);
-                return null;
+                percentPlayed = null;
             }
         }
 
-        private async Task<ActionResult> GetImageInternal(
-            Guid itemId,
-            ImageType imageType,
-            int? imageIndex,
-            string? tag,
-            ImageFormat? format,
-            int? maxWidth,
-            int? maxHeight,
-            double? percentPlayed,
-            int? unplayedCount,
-            int? width,
-            int? height,
-            int? quality,
-            int? fillWidth,
-            int? fillHeight,
-            bool? addPlayedIndicator,
-            int? blur,
-            string? backgroundColor,
-            string? foregroundLayer,
-            BaseItem? item,
-            ItemImageInfo? imageInfo = null)
-        {
-            if (percentPlayed.HasValue)
-            {
-                if (percentPlayed.Value <= 0)
-                {
-                    percentPlayed = null;
-                }
-                else if (percentPlayed.Value >= 100)
-                {
-                    percentPlayed = null;
-                    addPlayedIndicator = true;
-                }
-            }
-
-            if (percentPlayed.HasValue)
-            {
-                unplayedCount = null;
-            }
+        if (percentPlayed.HasValue)
+        {
+            unplayedCount = null;
+        }
 
-            if (unplayedCount.HasValue
-                && unplayedCount.Value <= 0)
-            {
-                unplayedCount = null;
-            }
+        if (unplayedCount.HasValue
+            && unplayedCount.Value <= 0)
+        {
+            unplayedCount = null;
+        }
 
+        if (imageInfo is null)
+        {
+            imageInfo = item?.GetImageInfo(imageType, imageIndex ?? 0);
             if (imageInfo is null)
             {
-                imageInfo = item?.GetImageInfo(imageType, imageIndex ?? 0);
-                if (imageInfo is null)
-                {
-                    return NotFound(string.Format(NumberFormatInfo.InvariantInfo, "{0} does not have an image of type {1}", item?.Name, imageType));
-                }
+                return NotFound(string.Format(NumberFormatInfo.InvariantInfo, "{0} does not have an image of type {1}", item?.Name, imageType));
             }
+        }
 
-            var outputFormats = GetOutputFormats(format);
-
-            TimeSpan? cacheDuration = null;
+        var outputFormats = GetOutputFormats(format);
 
-            if (!string.IsNullOrEmpty(tag))
-            {
-                cacheDuration = TimeSpan.FromDays(365);
-            }
-
-            var responseHeaders = new Dictionary<string, string>
-            {
-                { "transferMode.dlna.org", "Interactive" },
-                { "realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*" }
-            };
+        TimeSpan? cacheDuration = null;
 
-            if (!imageInfo.IsLocalFile && item is not null)
-            {
-                imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, imageIndex ?? 0).ConfigureAwait(false);
-            }
+        if (!string.IsNullOrEmpty(tag))
+        {
+            cacheDuration = TimeSpan.FromDays(365);
+        }
 
-            var options = new ImageProcessingOptions
-            {
-                Height = height,
-                ImageIndex = imageIndex ?? 0,
-                Image = imageInfo,
-                Item = item,
-                ItemId = itemId,
-                MaxHeight = maxHeight,
-                MaxWidth = maxWidth,
-                FillHeight = fillHeight,
-                FillWidth = fillWidth,
-                Quality = quality ?? 100,
-                Width = width,
-                AddPlayedIndicator = addPlayedIndicator ?? false,
-                PercentPlayed = percentPlayed ?? 0,
-                UnplayedCount = unplayedCount,
-                Blur = blur,
-                BackgroundColor = backgroundColor,
-                ForegroundLayer = foregroundLayer,
-                SupportedOutputFormats = outputFormats
-            };
+        var responseHeaders = new Dictionary<string, string>
+        {
+            { "transferMode.dlna.org", "Interactive" },
+            { "realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*" }
+        };
 
-            return await GetImageResult(
-                options,
-                cacheDuration,
-                responseHeaders).ConfigureAwait(false);
+        if (!imageInfo.IsLocalFile && item is not null)
+        {
+            imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, imageIndex ?? 0).ConfigureAwait(false);
         }
 
-        private ImageFormat[] GetOutputFormats(ImageFormat? format)
+        var options = new ImageProcessingOptions
         {
-            if (format.HasValue)
-            {
-                return new[] { format.Value };
-            }
+            Height = height,
+            ImageIndex = imageIndex ?? 0,
+            Image = imageInfo,
+            Item = item,
+            ItemId = itemId,
+            MaxHeight = maxHeight,
+            MaxWidth = maxWidth,
+            FillHeight = fillHeight,
+            FillWidth = fillWidth,
+            Quality = quality ?? 100,
+            Width = width,
+            PercentPlayed = percentPlayed ?? 0,
+            UnplayedCount = unplayedCount,
+            Blur = blur,
+            BackgroundColor = backgroundColor,
+            ForegroundLayer = foregroundLayer,
+            SupportedOutputFormats = outputFormats
+        };
+
+        return await GetImageResult(
+            options,
+            cacheDuration,
+            responseHeaders).ConfigureAwait(false);
+    }
 
-            return GetClientSupportedFormats();
+    private ImageFormat[] GetOutputFormats(ImageFormat? format)
+    {
+        if (format.HasValue)
+        {
+            return new[] { format.Value };
         }
 
-        private ImageFormat[] GetClientSupportedFormats()
+        return GetClientSupportedFormats();
+    }
+
+    private ImageFormat[] GetClientSupportedFormats()
+    {
+        var supportedFormats = Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept);
+        for (var i = 0; i < supportedFormats.Length; i++)
         {
-            var supportedFormats = Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept);
-            for (var i = 0; i < supportedFormats.Length; i++)
+            // Remove charsets etc. (anything after semi-colon)
+            var type = supportedFormats[i];
+            int index = type.IndexOf(';', StringComparison.Ordinal);
+            if (index != -1)
             {
-                // Remove charsets etc. (anything after semi-colon)
-                var type = supportedFormats[i];
-                int index = type.IndexOf(';', StringComparison.Ordinal);
-                if (index != -1)
-                {
-                    supportedFormats[i] = type.Substring(0, index);
-                }
+                supportedFormats[i] = type.Substring(0, index);
             }
+        }
 
-            var acceptParam = Request.Query[HeaderNames.Accept];
+        var acceptParam = Request.Query[HeaderNames.Accept];
 
-            var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false);
+        var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false);
 
-            if (!supportsWebP)
+        if (!supportsWebP)
+        {
+            var userAgent = Request.Headers[HeaderNames.UserAgent].ToString();
+            if (userAgent.Contains("crosswalk", StringComparison.OrdinalIgnoreCase)
+                && userAgent.Contains("android", StringComparison.OrdinalIgnoreCase))
             {
-                var userAgent = Request.Headers[HeaderNames.UserAgent].ToString();
-                if (userAgent.Contains("crosswalk", StringComparison.OrdinalIgnoreCase)
-                    && userAgent.Contains("android", StringComparison.OrdinalIgnoreCase))
-                {
-                    supportsWebP = true;
-                }
+                supportsWebP = true;
             }
+        }
 
-            var formats = new List<ImageFormat>(4);
+        var formats = new List<ImageFormat>(4);
 
-            if (supportsWebP)
-            {
-                formats.Add(ImageFormat.Webp);
-            }
+        if (supportsWebP)
+        {
+            formats.Add(ImageFormat.Webp);
+        }
 
-            formats.Add(ImageFormat.Jpg);
-            formats.Add(ImageFormat.Png);
+        formats.Add(ImageFormat.Jpg);
+        formats.Add(ImageFormat.Png);
 
-            if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true))
-            {
-                formats.Add(ImageFormat.Gif);
-            }
+        if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true))
+        {
+            formats.Add(ImageFormat.Gif);
+        }
+
+        return formats.ToArray();
+    }
 
-            return formats.ToArray();
+    private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll)
+    {
+        if (requestAcceptTypes.Contains(format.GetMimeType()))
+        {
+            return true;
         }
 
-        private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll)
+        if (acceptAll && requestAcceptTypes.Contains("*/*"))
         {
-            if (requestAcceptTypes.Contains(format.GetMimeType()))
-            {
-                return true;
-            }
+            return true;
+        }
 
-            if (acceptAll && requestAcceptTypes.Contains("*/*"))
-            {
-                return true;
-            }
+        // Review if this should be jpeg, jpg or both for ImageFormat.Jpg
+        var normalized = format.ToString().ToLowerInvariant();
+        return string.Equals(acceptParam, normalized, StringComparison.OrdinalIgnoreCase);
+    }
 
-            // Review if this should be jpeg, jpg or both for ImageFormat.Jpg
-            var normalized = format.ToString().ToLowerInvariant();
-            return string.Equals(acceptParam, normalized, StringComparison.OrdinalIgnoreCase);
+    private async Task<ActionResult> GetImageResult(
+        ImageProcessingOptions imageProcessingOptions,
+        TimeSpan? cacheDuration,
+        IDictionary<string, string> headers)
+    {
+        var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(imageProcessingOptions).ConfigureAwait(false);
+
+        var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache");
+        var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader);
+
+        // if the parsing of the IfModifiedSince header was not successful, disable caching
+        if (!parsingSuccessful)
+        {
+            // disableCaching = true;
         }
 
-        private async Task<ActionResult> GetImageResult(
-            ImageProcessingOptions imageProcessingOptions,
-            TimeSpan? cacheDuration,
-            IDictionary<string, string> headers)
+        foreach (var (key, value) in headers)
         {
-            var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(imageProcessingOptions).ConfigureAwait(false);
+            Response.Headers.Add(key, value);
+        }
 
-            var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache");
-            var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader);
+        Response.ContentType = imageContentType ?? MediaTypeNames.Text.Plain;
+        Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture));
+        Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept);
 
-            // if the parsing of the IfModifiedSince header was not successful, disable caching
-            if (!parsingSuccessful)
+        if (disableCaching)
+        {
+            Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate");
+            Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate");
+        }
+        else
+        {
+            if (cacheDuration.HasValue)
             {
-                // disableCaching = true;
+                Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds);
             }
-
-            foreach (var (key, value) in headers)
+            else
             {
-                Response.Headers.Add(key, value);
+                Response.Headers.Add(HeaderNames.CacheControl, "public");
             }
 
-            Response.ContentType = imageContentType ?? MediaTypeNames.Text.Plain;
-            Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture));
-            Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept);
+            Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture));
 
-            if (disableCaching)
+            // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified
+            if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue)
             {
-                Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate");
-                Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate");
-            }
-            else
-            {
-                if (cacheDuration.HasValue)
-                {
-                    Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds);
-                }
-                else
+                if (ifModifiedSinceHeader.Add(cacheDuration.Value) < DateTime.UtcNow)
                 {
-                    Response.Headers.Add(HeaderNames.CacheControl, "public");
-                }
-
-                Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture));
-
-                // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified
-                if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue)
-                {
-                    if (ifModifiedSinceHeader.Add(cacheDuration.Value) < DateTime.UtcNow)
-                    {
-                        Response.StatusCode = StatusCodes.Status304NotModified;
-                        return new ContentResult();
-                    }
+                    Response.StatusCode = StatusCodes.Status304NotModified;
+                    return new ContentResult();
                 }
             }
-
-            return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain);
         }
+
+        return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain);
     }
 }

+ 322 - 324
Jellyfin.Api/Controllers/InstantMixController.cs

@@ -1,7 +1,6 @@
 using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
@@ -16,346 +15,345 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The instant mix controller.
+/// </summary>
+[Route("")]
+[Authorize]
+public class InstantMixController : BaseJellyfinApiController
 {
+    private readonly IUserManager _userManager;
+    private readonly IDtoService _dtoService;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IMusicManager _musicManager;
+
     /// <summary>
-    /// The instant mix controller.
+    /// Initializes a new instance of the <see cref="InstantMixController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class InstantMixController : BaseJellyfinApiController
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+    /// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    public InstantMixController(
+        IUserManager userManager,
+        IDtoService dtoService,
+        IMusicManager musicManager,
+        ILibraryManager libraryManager)
     {
-        private readonly IUserManager _userManager;
-        private readonly IDtoService _dtoService;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IMusicManager _musicManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="InstantMixController"/> class.
-        /// </summary>
-        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
-        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
-        /// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        public InstantMixController(
-            IUserManager userManager,
-            IDtoService dtoService,
-            IMusicManager musicManager,
-            ILibraryManager libraryManager)
-        {
-            _userManager = userManager;
-            _dtoService = dtoService;
-            _musicManager = musicManager;
-            _libraryManager = libraryManager;
-        }
+        _userManager = userManager;
+        _dtoService = dtoService;
+        _musicManager = musicManager;
+        _libraryManager = libraryManager;
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given song.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Songs/{id}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong(
-            [FromRoute, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var item = _libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given song.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Songs/{id}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong(
+        [FromRoute, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var item = _libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given album.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Albums/{id}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum(
-            [FromRoute, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var album = _libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given album.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Albums/{id}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum(
+        [FromRoute, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var album = _libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given playlist.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Playlists/{id}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist(
-            [FromRoute, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var playlist = (Playlist)_libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given playlist.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Playlists/{id}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist(
+        [FromRoute, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var playlist = (Playlist)_libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given genre.
-        /// </summary>
-        /// <param name="name">The genre name.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("MusicGenres/{name}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName(
-            [FromRoute, Required] string name,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given genre.
+    /// </summary>
+    /// <param name="name">The genre name.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("MusicGenres/{name}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName(
+        [FromRoute, Required] string name,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given artist.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Artists/{id}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
-            [FromRoute, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var item = _libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given artist.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Artists/{id}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
+        [FromRoute, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var item = _libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given item.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Items/{id}/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem(
-            [FromRoute, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var item = _libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given item.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Items/{id}/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem(
+        [FromRoute, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var item = _libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given artist.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Artists/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [Obsolete("Use GetInstantMixFromArtists")]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2(
-            [FromQuery, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            return GetInstantMixFromArtists(
-                id,
-                userId,
-                limit,
-                fields,
-                enableImages,
-                enableUserData,
-                imageTypeLimit,
-                enableImageTypes);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given artist.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("Artists/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Use GetInstantMixFromArtists")]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2(
+        [FromQuery, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        return GetInstantMixFromArtists(
+            id,
+            userId,
+            limit,
+            fields,
+            enableImages,
+            enableUserData,
+            imageTypeLimit,
+            enableImageTypes);
+    }
 
-        /// <summary>
-        /// Creates an instant playlist based on a given genre.
-        /// </summary>
-        /// <param name="id">The item id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
-        /// <param name="enableImages">Optional. Include image information in output.</param>
-        /// <param name="enableUserData">Optional. Include user data.</param>
-        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
-        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <response code="200">Instant playlist returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("MusicGenres/InstantMix")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById(
-            [FromQuery, Required] Guid id,
-            [FromQuery] Guid? userId,
-            [FromQuery] int? limit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
-            [FromQuery] bool? enableImages,
-            [FromQuery] bool? enableUserData,
-            [FromQuery] int? imageTypeLimit,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
-        {
-            var item = _libraryManager.GetItemById(id);
-            var user = userId is null || userId.Value.Equals(default)
-                ? null
-                : _userManager.GetUserById(userId.Value);
-            var dtoOptions = new DtoOptions { Fields = fields }
-                .AddClientFields(User)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-            return GetResult(items, user, limit, dtoOptions);
-        }
+    /// <summary>
+    /// Creates an instant playlist based on a given genre.
+    /// </summary>
+    /// <param name="id">The item id.</param>
+    /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+    /// <param name="limit">Optional. The maximum number of records to return.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="enableUserData">Optional. Include user data.</param>
+    /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+    /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+    /// <response code="200">Instant playlist returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+    [HttpGet("MusicGenres/InstantMix")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById(
+        [FromQuery, Required] Guid id,
+        [FromQuery] Guid? userId,
+        [FromQuery] int? limit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery] bool? enableImages,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
+    {
+        var item = _libraryManager.GetItemById(id);
+        var user = userId is null || userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
+        var dtoOptions = new DtoOptions { Fields = fields }
+            .AddClientFields(User)
+            .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+        var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+        return GetResult(items, user, limit, dtoOptions);
+    }
 
-        private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions)
-        {
-            var list = items;
+    private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions)
+    {
+        var list = items;
 
-            var totalCount = list.Count;
+        var totalCount = list.Count;
 
-            if (limit.HasValue && limit < list.Count)
-            {
-                list = list.GetRange(0, limit.Value);
-            }
+        if (limit.HasValue && limit < list.Count)
+        {
+            list = list.GetRange(0, limit.Value);
+        }
 
-            var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);
+        var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);
 
-            var result = new QueryResult<BaseItemDto>(
-                0,
-                totalCount,
-                returnList);
+        var result = new QueryResult<BaseItemDto>(
+            0,
+            totalCount,
+            returnList);
 
-            return result;
-        }
+        return result;
     }
 }

+ 231 - 233
Jellyfin.Api/Controllers/ItemLookupController.cs

@@ -1,7 +1,6 @@
 using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
-using System.Text.Json;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
@@ -18,257 +17,256 @@ using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.Extensions.Logging;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Item lookup controller.
+/// </summary>
+[Route("")]
+[Authorize]
+public class ItemLookupController : BaseJellyfinApiController
 {
+    private readonly IProviderManager _providerManager;
+    private readonly IFileSystem _fileSystem;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILogger<ItemLookupController> _logger;
+
     /// <summary>
-    /// Item lookup controller.
+    /// Initializes a new instance of the <see cref="ItemLookupController"/> class.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.DefaultAuthorization)]
-    public class ItemLookupController : BaseJellyfinApiController
+    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param>
+    public ItemLookupController(
+        IProviderManager providerManager,
+        IFileSystem fileSystem,
+        ILibraryManager libraryManager,
+        ILogger<ItemLookupController> logger)
     {
-        private readonly IProviderManager _providerManager;
-        private readonly IFileSystem _fileSystem;
-        private readonly ILibraryManager _libraryManager;
-        private readonly ILogger<ItemLookupController> _logger;
+        _providerManager = providerManager;
+        _fileSystem = fileSystem;
+        _libraryManager = libraryManager;
+        _logger = logger;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ItemLookupController"/> class.
-        /// </summary>
-        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param>
-        public ItemLookupController(
-            IProviderManager providerManager,
-            IFileSystem fileSystem,
-            ILibraryManager libraryManager,
-            ILogger<ItemLookupController> logger)
+    /// <summary>
+    /// Get the item's external id info.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">External id info retrieved.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>List of external id info.</returns>
+    [HttpGet("Items/{itemId}/ExternalIdInfos")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
         {
-            _providerManager = providerManager;
-            _fileSystem = fileSystem;
-            _libraryManager = libraryManager;
-            _logger = logger;
+            return NotFound();
         }
 
-        /// <summary>
-        /// Get the item's external id info.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <response code="200">External id info retrieved.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>List of external id info.</returns>
-        [HttpGet("Items/{itemId}/ExternalIdInfos")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
-
-            return Ok(_providerManager.GetExternalIdInfos(item));
-        }
+        return Ok(_providerManager.GetExternalIdInfos(item));
+    }
 
-        /// <summary>
-        /// Get movie remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Movie remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Movie")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MovieInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get movie remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Movie remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Movie")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MovieInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get trailer remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Trailer remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Trailer")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery<TrailerInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get trailer remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Trailer remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Trailer")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery<TrailerInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get music video remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Music video remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/MusicVideo")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MusicVideoInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get music video remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Music video remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/MusicVideo")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MusicVideoInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get series remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Series remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Series")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery<SeriesInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get series remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Series remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Series")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery<SeriesInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get box set remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Box set remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/BoxSet")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BoxSetInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get box set remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Box set remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/BoxSet")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BoxSetInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get music artist remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Music artist remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/MusicArtist")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery<ArtistInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get music artist remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Music artist remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/MusicArtist")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery<ArtistInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get music album remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Music album remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/MusicAlbum")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery<AlbumInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get music album remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Music album remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/MusicAlbum")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery<AlbumInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get person remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Person remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Person")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery<PersonLookupInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get person remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Person remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Person")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery<PersonLookupInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Get book remote search.
-        /// </summary>
-        /// <param name="query">Remote search query.</param>
-        /// <response code="200">Book remote search executed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Book")]
-        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BookInfo> query)
-        {
-            var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None)
-                .ConfigureAwait(false);
-            return Ok(results);
-        }
+    /// <summary>
+    /// Get book remote search.
+    /// </summary>
+    /// <param name="query">Remote search query.</param>
+    /// <response code="200">Book remote search executed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Book")]
+    public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BookInfo> query)
+    {
+        var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None)
+            .ConfigureAwait(false);
+        return Ok(results);
+    }
 
-        /// <summary>
-        /// Applies search criteria to an item and refreshes metadata.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="searchResult">The remote search result.</param>
-        /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param>
-        /// <response code="204">Item metadata refreshed.</response>
-        /// <returns>
-        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
-        /// The task result contains an <see cref="NoContentResult"/>.
-        /// </returns>
-        [HttpPost("Items/RemoteSearch/Apply/{itemId}")]
-        [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> ApplySearchCriteria(
-            [FromRoute, Required] Guid itemId,
-            [FromBody, Required] RemoteSearchResult searchResult,
-            [FromQuery] bool replaceAllImages = true)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            _logger.LogInformation(
-                "Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}",
-                item.Id,
-                item.Name,
-                searchResult.ProviderIds);
+    /// <summary>
+    /// Applies search criteria to an item and refreshes metadata.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="searchResult">The remote search result.</param>
+    /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param>
+    /// <response code="204">Item metadata refreshed.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+    /// The task result contains an <see cref="NoContentResult"/>.
+    /// </returns>
+    [HttpPost("Items/RemoteSearch/Apply/{itemId}")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    public async Task<ActionResult> ApplySearchCriteria(
+        [FromRoute, Required] Guid itemId,
+        [FromBody, Required] RemoteSearchResult searchResult,
+        [FromQuery] bool replaceAllImages = true)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        _logger.LogInformation(
+            "Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}",
+            item.Id,
+            item.Name,
+            searchResult.ProviderIds);
 
-            // Since the refresh process won't erase provider Ids, we need to set this explicitly now.
-            item.ProviderIds = searchResult.ProviderIds;
-            await _providerManager.RefreshFullItem(
-                item,
-                new MetadataRefreshOptions(new DirectoryService(_fileSystem))
-                {
-                    MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
-                    ImageRefreshMode = MetadataRefreshMode.FullRefresh,
-                    ReplaceAllMetadata = true,
-                    ReplaceAllImages = replaceAllImages,
-                    SearchResult = searchResult,
-                    RemoveOldMetadata = true
-                },
-                CancellationToken.None).ConfigureAwait(false);
+        // Since the refresh process won't erase provider Ids, we need to set this explicitly now.
+        item.ProviderIds = searchResult.ProviderIds;
+        await _providerManager.RefreshFullItem(
+            item,
+            new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+            {
+                MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+                ImageRefreshMode = MetadataRefreshMode.FullRefresh,
+                ReplaceAllMetadata = true,
+                ReplaceAllImages = replaceAllImages,
+                SearchResult = searchResult,
+                RemoveOldMetadata = true
+            },
+            CancellationToken.None).ConfigureAwait(false);
 
-            return NoContent();
-        }
+        return NoContent();
     }
 }

+ 62 - 63
Jellyfin.Api/Controllers/ItemRefreshController.cs

@@ -9,78 +9,77 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Item Refresh Controller.
+/// </summary>
+[Route("Items")]
+[Authorize(Policy = Policies.RequiresElevation)]
+public class ItemRefreshController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IProviderManager _providerManager;
+    private readonly IFileSystem _fileSystem;
+
     /// <summary>
-    /// Item Refresh Controller.
+    /// Initializes a new instance of the <see cref="ItemRefreshController"/> class.
     /// </summary>
-    [Route("Items")]
-    [Authorize(Policy = Policies.RequiresElevation)]
-    public class ItemRefreshController : BaseJellyfinApiController
+    /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param>
+    /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
+    public ItemRefreshController(
+        ILibraryManager libraryManager,
+        IProviderManager providerManager,
+        IFileSystem fileSystem)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IProviderManager _providerManager;
-        private readonly IFileSystem _fileSystem;
+        _libraryManager = libraryManager;
+        _providerManager = providerManager;
+        _fileSystem = fileSystem;
+    }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ItemRefreshController"/> class.
-        /// </summary>
-        /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param>
-        /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
-        public ItemRefreshController(
-            ILibraryManager libraryManager,
-            IProviderManager providerManager,
-            IFileSystem fileSystem)
+    /// <summary>
+    /// Refreshes metadata for an item.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param>
+    /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param>
+    /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param>
+    /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param>
+    /// <response code="204">Item metadata refresh queued.</response>
+    /// <response code="404">Item to refresh not found.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+    [HttpPost("{itemId}/Refresh")]
+    [Description("Refreshes metadata for an item.")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult RefreshItem(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None,
+        [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None,
+        [FromQuery] bool replaceAllMetadata = false,
+        [FromQuery] bool replaceAllImages = false)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
         {
-            _libraryManager = libraryManager;
-            _providerManager = providerManager;
-            _fileSystem = fileSystem;
+            return NotFound();
         }
 
-        /// <summary>
-        /// Refreshes metadata for an item.
-        /// </summary>
-        /// <param name="itemId">Item id.</param>
-        /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param>
-        /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param>
-        /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param>
-        /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param>
-        /// <response code="204">Item metadata refresh queued.</response>
-        /// <response code="404">Item to refresh not found.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
-        [HttpPost("{itemId}/Refresh")]
-        [Description("Refreshes metadata for an item.")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult RefreshItem(
-            [FromRoute, Required] Guid itemId,
-            [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None,
-            [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None,
-            [FromQuery] bool replaceAllMetadata = false,
-            [FromQuery] bool replaceAllImages = false)
+        var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
         {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+            MetadataRefreshMode = metadataRefreshMode,
+            ImageRefreshMode = imageRefreshMode,
+            ReplaceAllImages = replaceAllImages,
+            ReplaceAllMetadata = replaceAllMetadata,
+            ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh
+                || imageRefreshMode == MetadataRefreshMode.FullRefresh
+                || replaceAllImages
+                || replaceAllMetadata,
+            IsAutomated = false
+        };
 
-            var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
-            {
-                MetadataRefreshMode = metadataRefreshMode,
-                ImageRefreshMode = imageRefreshMode,
-                ReplaceAllImages = replaceAllImages,
-                ReplaceAllMetadata = replaceAllMetadata,
-                ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh
-                    || imageRefreshMode == MetadataRefreshMode.FullRefresh
-                    || replaceAllImages
-                    || replaceAllMetadata,
-                IsAutomated = false
-            };
-
-            _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High);
-            return NoContent();
-        }
+        _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High);
+        return NoContent();
     }
 }

+ 332 - 333
Jellyfin.Api/Controllers/ItemUpdateController.cs

@@ -20,332 +20,332 @@ using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
-namespace Jellyfin.Api.Controllers
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Item update controller.
+/// </summary>
+[Route("")]
+[Authorize(Policy = Policies.RequiresElevation)]
+public class ItemUpdateController : BaseJellyfinApiController
 {
+    private readonly ILibraryManager _libraryManager;
+    private readonly IProviderManager _providerManager;
+    private readonly ILocalizationManager _localizationManager;
+    private readonly IFileSystem _fileSystem;
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ItemUpdateController"/> class.
+    /// </summary>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+    /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    public ItemUpdateController(
+        IFileSystem fileSystem,
+        ILibraryManager libraryManager,
+        IProviderManager providerManager,
+        ILocalizationManager localizationManager,
+        IServerConfigurationManager serverConfigurationManager)
+    {
+        _libraryManager = libraryManager;
+        _providerManager = providerManager;
+        _localizationManager = localizationManager;
+        _fileSystem = fileSystem;
+        _serverConfigurationManager = serverConfigurationManager;
+    }
+
     /// <summary>
-    /// Item update controller.
+    /// Updates an item.
     /// </summary>
-    [Route("")]
-    [Authorize(Policy = Policies.RequiresElevation)]
-    public class ItemUpdateController : BaseJellyfinApiController
+    /// <param name="itemId">The item id.</param>
+    /// <param name="request">The new item properties.</param>
+    /// <response code="204">Item updated.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+    [HttpPost("Items/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request)
     {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IProviderManager _providerManager;
-        private readonly ILocalizationManager _localizationManager;
-        private readonly IFileSystem _fileSystem;
-        private readonly IServerConfigurationManager _serverConfigurationManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ItemUpdateController"/> class.
-        /// </summary>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
-        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
-        /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
-        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
-        public ItemUpdateController(
-            IFileSystem fileSystem,
-            ILibraryManager libraryManager,
-            IProviderManager providerManager,
-            ILocalizationManager localizationManager,
-            IServerConfigurationManager serverConfigurationManager)
-        {
-            _libraryManager = libraryManager;
-            _providerManager = providerManager;
-            _localizationManager = localizationManager;
-            _fileSystem = fileSystem;
-            _serverConfigurationManager = serverConfigurationManager;
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
         }
 
-        /// <summary>
-        /// Updates an item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="request">The new item properties.</param>
-        /// <response code="204">Item updated.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
-        [HttpPost("Items/{itemId}")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        var newLockData = request.LockData ?? false;
+        var isLockedChanged = item.IsLocked != newLockData;
 
-            var newLockData = request.LockData ?? false;
-            var isLockedChanged = item.IsLocked != newLockData;
+        var series = item as Series;
+        var displayOrderChanged = series is not null && !string.Equals(
+            series.DisplayOrder ?? string.Empty,
+            request.DisplayOrder ?? string.Empty,
+            StringComparison.OrdinalIgnoreCase);
 
-            var series = item as Series;
-            var displayOrderChanged = series is not null && !string.Equals(
-                series.DisplayOrder ?? string.Empty,
-                request.DisplayOrder ?? string.Empty,
-                StringComparison.OrdinalIgnoreCase);
+        // Do this first so that metadata savers can pull the updates from the database.
+        if (request.People is not null)
+        {
+            _libraryManager.UpdatePeople(
+                item,
+                request.People.Select(x => new PersonInfo
+                {
+                    Name = x.Name,
+                    Role = x.Role,
+                    Type = x.Type
+                }).ToList());
+        }
 
-            // Do this first so that metadata savers can pull the updates from the database.
-            if (request.People is not null)
-            {
-                _libraryManager.UpdatePeople(
-                    item,
-                    request.People.Select(x => new PersonInfo
-                    {
-                        Name = x.Name,
-                        Role = x.Role,
-                        Type = x.Type
-                    }).ToList());
-            }
+        UpdateItem(request, item);
 
-            UpdateItem(request, item);
+        item.OnMetadataChanged();
 
-            item.OnMetadataChanged();
+        await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
 
-            await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+        if (isLockedChanged && item.IsFolder)
+        {
+            var folder = (Folder)item;
 
-            if (isLockedChanged && item.IsFolder)
+            foreach (var child in folder.GetRecursiveChildren())
             {
-                var folder = (Folder)item;
+                child.IsLocked = newLockData;
+                await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+            }
+        }
 
-                foreach (var child in folder.GetRecursiveChildren())
+        if (displayOrderChanged)
+        {
+            _providerManager.QueueRefresh(
+                series!.Id,
+                new MetadataRefreshOptions(new DirectoryService(_fileSystem))
                 {
-                    child.IsLocked = newLockData;
-                    await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
-                }
-            }
+                    MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+                    ImageRefreshMode = MetadataRefreshMode.FullRefresh,
+                    ReplaceAllMetadata = true
+                },
+                RefreshPriority.High);
+        }
 
-            if (displayOrderChanged)
-            {
-                _providerManager.QueueRefresh(
-                    series!.Id,
-                    new MetadataRefreshOptions(new DirectoryService(_fileSystem))
-                    {
-                        MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
-                        ImageRefreshMode = MetadataRefreshMode.FullRefresh,
-                        ReplaceAllMetadata = true
-                    },
-                    RefreshPriority.High);
-            }
+        return NoContent();
+    }
 
-            return NoContent();
-        }
+    /// <summary>
+    /// Gets metadata editor info for an item.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <response code="200">Item metadata editor returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+    [HttpGet("Items/{itemId}/MetadataEditor")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId)
+    {
+        var item = _libraryManager.GetItemById(itemId);
 
-        /// <summary>
-        /// Gets metadata editor info for an item.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <response code="200">Item metadata editor returned.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
-        [HttpGet("Items/{itemId}/MetadataEditor")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-
-            var info = new MetadataEditorInfo
-            {
-                ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(),
-                ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(),
-                Countries = _localizationManager.GetCountries().ToArray(),
-                Cultures = _localizationManager.GetCultures().ToArray()
-            };
-
-            if (!item.IsVirtualItem
-                && item is not ICollectionFolder
-                && item is not UserView
-                && item is not AggregateFolder
-                && item is not LiveTvChannel
-                && item is not IItemByName
-                && item.SourceType == SourceType.Library)
+        var info = new MetadataEditorInfo
+        {
+            ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(),
+            ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(),
+            Countries = _localizationManager.GetCountries().ToArray(),
+            Cultures = _localizationManager.GetCultures().ToArray()
+        };
+
+        if (!item.IsVirtualItem
+            && item is not ICollectionFolder
+            && item is not UserView
+            && item is not AggregateFolder
+            && item is not LiveTvChannel
+            && item is not IItemByName
+            && item.SourceType == SourceType.Library)
+        {
+            var inheritedContentType = _libraryManager.GetInheritedContentType(item);
+            var configuredContentType = _libraryManager.GetConfiguredContentType(item);
+
+            if (string.IsNullOrWhiteSpace(inheritedContentType) ||
+                !string.IsNullOrWhiteSpace(configuredContentType))
             {
-                var inheritedContentType = _libraryManager.GetInheritedContentType(item);
-                var configuredContentType = _libraryManager.GetConfiguredContentType(item);
+                info.ContentTypeOptions = GetContentTypeOptions(true).ToArray();
+                info.ContentType = configuredContentType;
 
-                if (string.IsNullOrWhiteSpace(inheritedContentType) ||
-                    !string.IsNullOrWhiteSpace(configuredContentType))
+                if (string.IsNullOrWhiteSpace(inheritedContentType)
+                    || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
                 {
-                    info.ContentTypeOptions = GetContentTypeOptions(true).ToArray();
-                    info.ContentType = configuredContentType;
-
-                    if (string.IsNullOrWhiteSpace(inheritedContentType)
-                        || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
-                    {
-                        info.ContentTypeOptions = info.ContentTypeOptions
-                            .Where(i => string.IsNullOrWhiteSpace(i.Value)
-                                        || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
-                            .ToArray();
-                    }
+                    info.ContentTypeOptions = info.ContentTypeOptions
+                        .Where(i => string.IsNullOrWhiteSpace(i.Value)
+                                    || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
+                        .ToArray();
                 }
             }
-
-            return info;
         }
 
-        /// <summary>
-        /// Updates an item's content type.
-        /// </summary>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="contentType">The content type of the item.</param>
-        /// <response code="204">Item content type updated.</response>
-        /// <response code="404">Item not found.</response>
-        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
-        [HttpPost("Items/{itemId}/ContentType")]
-        [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-            if (item is null)
-            {
-                return NotFound();
-            }
+        return info;
+    }
 
-            var path = item.ContainingFolderPath;
+    /// <summary>
+    /// Updates an item's content type.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="contentType">The content type of the item.</param>
+    /// <response code="204">Item content type updated.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+    [HttpPost("Items/{itemId}/ContentType")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType)
+    {
+        var item = _libraryManager.GetItemById(itemId);
+        if (item is null)
+        {
+            return NotFound();
+        }
 
-            var types = _serverConfigurationManager.Configuration.ContentTypes
-                .Where(i => !string.IsNullOrWhiteSpace(i.Name))
-                .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase))
-                .ToList();
+        var path = item.ContainingFolderPath;
 
-            if (!string.IsNullOrWhiteSpace(contentType))
-            {
-                types.Add(new NameValuePair
-                {
-                    Name = path,
-                    Value = contentType
-                });
-            }
+        var types = _serverConfigurationManager.Configuration.ContentTypes
+            .Where(i => !string.IsNullOrWhiteSpace(i.Name))
+            .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase))
+            .ToList();
 
-            _serverConfigurationManager.Configuration.ContentTypes = types.ToArray();
-            _serverConfigurationManager.SaveConfiguration();
-            return NoContent();
+        if (!string.IsNullOrWhiteSpace(contentType))
+        {
+            types.Add(new NameValuePair
+            {
+                Name = path,
+                Value = contentType
+            });
         }
 
-        private void UpdateItem(BaseItemDto request, BaseItem item)
-        {
-            item.Name = request.Name;
-            item.ForcedSortName = request.ForcedSortName;
+        _serverConfigurationManager.Configuration.ContentTypes = types.ToArray();
+        _serverConfigurationManager.SaveConfiguration();
+        return NoContent();
+    }
 
-            item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle;
+    private void UpdateItem(BaseItemDto request, BaseItem item)
+    {
+        item.Name = request.Name;
+        item.ForcedSortName = request.ForcedSortName;
 
-            item.CriticRating = request.CriticRating;
+        item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle;
 
-            item.CommunityRating = request.CommunityRating;
-            item.IndexNumber = request.IndexNumber;
-            item.ParentIndexNumber = request.ParentIndexNumber;
-            item.Overview = request.Overview;
-            item.Genres = request.Genres;
+        item.CriticRating = request.CriticRating;
 
-            if (item is Episode episode)
-            {
-                episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber;
-                episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber;
-                episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber;
-            }
+        item.CommunityRating = request.CommunityRating;
+        item.IndexNumber = request.IndexNumber;
+        item.ParentIndexNumber = request.ParentIndexNumber;
+        item.Overview = request.Overview;
+        item.Genres = request.Genres;
 
-            item.Tags = request.Tags;
+        if (item is Episode episode)
+        {
+            episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber;
+            episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber;
+            episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber;
+        }
 
-            if (request.Taglines is not null)
-            {
-                item.Tagline = request.Taglines.FirstOrDefault();
-            }
+        item.Tags = request.Tags;
 
-            if (request.Studios is not null)
-            {
-                item.Studios = request.Studios.Select(x => x.Name).ToArray();
-            }
+        if (request.Taglines is not null)
+        {
+            item.Tagline = request.Taglines.FirstOrDefault();
+        }
 
-            if (request.DateCreated.HasValue)
-            {
-                item.DateCreated = NormalizeDateTime(request.DateCreated.Value);
-            }
+        if (request.Studios is not null)
+        {
+            item.Studios = request.Studios.Select(x => x.Name).ToArray();
+        }
 
-            item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null;
-            item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null;
-            item.ProductionYear = request.ProductionYear;
-            item.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating;
-            item.CustomRating = request.CustomRating;
+        if (request.DateCreated.HasValue)
+        {
+            item.DateCreated = NormalizeDateTime(request.DateCreated.Value);
+        }
 
-            if (request.ProductionLocations is not null)
-            {
-                item.ProductionLocations = request.ProductionLocations;
-            }
+        item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null;
+        item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null;
+        item.ProductionYear = request.ProductionYear;
+        item.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating;
+        item.CustomRating = request.CustomRating;
 
-            item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode;
-            item.PreferredMetadataLanguage = request.PreferredMetadataLanguage;
+        if (request.ProductionLocations is not null)
+        {
+            item.ProductionLocations = request.ProductionLocations;
+        }
 
-            if (item is IHasDisplayOrder hasDisplayOrder)
-            {
-                hasDisplayOrder.DisplayOrder = request.DisplayOrder;
-            }
+        item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode;
+        item.PreferredMetadataLanguage = request.PreferredMetadataLanguage;
 
-            if (item is IHasAspectRatio hasAspectRatio)
-            {
-                hasAspectRatio.AspectRatio = request.AspectRatio;
-            }
+        if (item is IHasDisplayOrder hasDisplayOrder)
+        {
+            hasDisplayOrder.DisplayOrder = request.DisplayOrder;
+        }
+
+        if (item is IHasAspectRatio hasAspectRatio)
+        {
+            hasAspectRatio.AspectRatio = request.AspectRatio;
+        }
 
-            item.IsLocked = request.LockData ?? false;
+        item.IsLocked = request.LockData ?? false;
 
-            if (request.LockedFields is not null)
-            {
-                item.LockedFields = request.LockedFields;
-            }
+        if (request.LockedFields is not null)
+        {
+            item.LockedFields = request.LockedFields;
+        }
 
-            // Only allow this for series. Runtimes for media comes from ffprobe.
-            if (item is Series)
-            {
-                item.RunTimeTicks = request.RunTimeTicks;
-            }
+        // Only allow this for series. Runtimes for media comes from ffprobe.
+        if (item is Series)
+        {
+            item.RunTimeTicks = request.RunTimeTicks;
+        }
 
-            foreach (var pair in request.ProviderIds.ToList())
+        foreach (var pair in request.ProviderIds.ToList())
+        {
+            if (string.IsNullOrEmpty(pair.Value))
             {
-                if (string.IsNullOrEmpty(pair.Value))
-                {
-                    request.ProviderIds.Remove(pair.Key);
-                }
+                request.ProviderIds.Remove(pair.Key);
             }
+        }
 
-            item.ProviderIds = request.ProviderIds;
+        item.ProviderIds = request.ProviderIds;
 
-            if (item is Video video)
-            {
-                video.Video3DFormat = request.Video3DFormat;
-            }
+        if (item is Video video)
+        {
+            video.Video3DFormat = request.Video3DFormat;
+        }
 
-            if (request.AlbumArtists is not null)
+        if (request.AlbumArtists is not null)
+        {
+            if (item is IHasAlbumArtist hasAlbumArtists)
             {
-                if (item is IHasAlbumArtist hasAlbumArtists)
-                {
-                    hasAlbumArtists.AlbumArtists = request
-                        .AlbumArtists
-                        .Select(i => i.Name)
-                        .ToArray();
-                }
+                hasAlbumArtists.AlbumArtists = request
+                    .AlbumArtists
+                    .Select(i => i.Name)
+                    .ToArray();
             }
+        }
 
-            if (request.ArtistItems is not null)
+        if (request.ArtistItems is not null)
+        {
+            if (item is IHasArtist hasArtists)
             {
-                if (item is IHasArtist hasArtists)
-                {
-                    hasArtists.Artists = request
-                        .ArtistItems
-                        .Select(i => i.Name)
-                        .ToArray();
-                }
+                hasArtists.Artists = request
+                    .ArtistItems
+                    .Select(i => i.Name)
+                    .ToArray();
             }
+        }
 
-            switch (item)
-            {
-                case Audio song:
-                    song.Album = request.Album;
-                    break;
-                case MusicVideo musicVideo:
-                    musicVideo.Album = request.Album;
-                    break;
-                case Series series:
+        switch (item)
+        {
+            case Audio song:
+                song.Album = request.Album;
+                break;
+            case MusicVideo musicVideo:
+                musicVideo.Album = request.Album;
+                break;
+            case Series series:
                 {
                     series.Status = GetSeriesStatus(request);
 
@@ -357,93 +357,92 @@ namespace Jellyfin.Api.Controllers
 
                     break;
                 }
-            }
         }
+    }
 
-        private SeriesStatus? GetSeriesStatus(BaseItemDto item)
+    private SeriesStatus? GetSeriesStatus(BaseItemDto item)
+    {
+        if (string.IsNullOrEmpty(item.Status))
         {
-            if (string.IsNullOrEmpty(item.Status))
-            {
-                return null;
-            }
-
-            return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true);
+            return null;
         }
 
-        private DateTime NormalizeDateTime(DateTime val)
-        {
-            return DateTime.SpecifyKind(val, DateTimeKind.Utc);
-        }
+        return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true);
+    }
 
-        private List<NameValuePair> GetContentTypeOptions(bool isForItem)
-        {
-            var list = new List<NameValuePair>();
+    private DateTime NormalizeDateTime(DateTime val)
+    {
+        return DateTime.SpecifyKind(val, DateTimeKind.Utc);
+    }
 
-            if (isForItem)
-            {
-                list.Add(new NameValuePair
-                {
-                    Name = "Inherit",
-                    Value = string.Empty
-                });
-            }
+    private List<NameValuePair> GetContentTypeOptions(bool isForItem)
+    {
+        var list = new List<NameValuePair>();
 
+        if (isForItem)
+        {
             list.Add(new NameValuePair
             {
-                Name = "Movies",
-                Value = "movies"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "Music",
-                Value = "music"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "Shows",
-                Value = "tvshows"
+                Name = "Inherit",
+                Value = string.Empty
             });
+        }
 
-            if (!isForItem)
-            {
-                list.Add(new NameValuePair
-                {
-                    Name = "Books",
-                    Value = "books"
-                });
-            }
+        list.Add(new NameValuePair
+        {
+            Name = "Movies",
+            Value = "movies"
+        });
+        list.Add(new NameValuePair
+        {
+            Name = "Music",
+            Value = "music"
+        });
+        list.Add(new NameValuePair
+        {
+            Name = "Shows",
+            Value = "tvshows"
+        });
 
+        if (!isForItem)
+        {
             list.Add(new NameValuePair
             {
-                Name = "HomeVideos",
-                Value = "homevideos"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "MusicVideos",
-                Value = "musicvideos"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "Photos",
-                Value = "photos"
+                Name = "Books",
+                Value = "books"
             });
+        }
 
-            if (!isForItem)
-            {
-                list.Add(new NameValuePair
-                {
-                    Name = "MixedContent",
-                    Value = string.Empty
-                });
-            }
+        list.Add(new NameValuePair
+        {
+            Name = "HomeVideos",
+            Value = "homevideos"
+        });
+        list.Add(new NameValuePair
+        {
+            Name = "MusicVideos",
+            Value = "musicvideos"
+        });
+        list.Add(new NameValuePair
+        {
+            Name = "Photos",
+            Value = "photos"
+        });
 
-            foreach (var val in list)
+        if (!isForItem)
+        {
+            list.Add(new NameValuePair
             {
-                val.Name = _localizationManager.GetLocalizedString(val.Name);
-            }
+                Name = "MixedContent",
+                Value = string.Empty
+            });
+        }
 
-            return list;
+        foreach (var val in list)
+        {
+            val.Name = _localizationManager.GetLocalizedString(val.Name);
         }
+
+        return list;
     }
 }

部分文件因为文件数量过多而无法显示