Browse Source

Merge remote-tracking branch 'upstream/master' into fmp4-hls

nyanmisaka 4 years ago
parent
commit
488dbdb31d
100 changed files with 983 additions and 1378 deletions
  1. 1 1
      .ci/azure-pipelines-abi.yml
  2. 0 19
      .ci/azure-pipelines-api-client.yml
  3. 1 1
      .ci/azure-pipelines-main.yml
  4. 2 2
      .ci/azure-pipelines-test.yml
  5. 1 1
      .ci/azure-pipelines.yml
  6. 2 2
      .vscode/launch.json
  7. 2 2
      Dockerfile
  8. 2 2
      Dockerfile.arm
  9. 2 2
      Dockerfile.arm64
  10. 1 1
      DvdLib/DvdLib.csproj
  11. 1 1
      DvdLib/Ifo/Dvd.cs
  12. 1 1
      Emby.Dlna/Didl/Filter.cs
  13. 1 1
      Emby.Dlna/Emby.Dlna.csproj
  14. 1 1
      Emby.Dlna/Eventing/DlnaEventManager.cs
  15. 1 0
      Emby.Dlna/Service/BaseControlHandler.cs
  16. 1 1
      Emby.Drawing/Emby.Drawing.csproj
  17. 1 1
      Emby.Naming/Emby.Naming.csproj
  18. 5 0
      Emby.Naming/Video/CleanDateTimeParser.cs
  19. 1 1
      Emby.Notifications/Emby.Notifications.csproj
  20. 5 5
      Emby.Notifications/NotificationEntryPoint.cs
  21. 1 1
      Emby.Photos/Emby.Photos.csproj
  22. 27 0
      Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs
  23. 4 2
      Emby.Server.Implementations/AppBase/ConfigurationHelper.cs
  24. 67 84
      Emby.Server.Implementations/ApplicationHost.cs
  25. 2 1
      Emby.Server.Implementations/Cryptography/CryptographyProvider.cs
  26. 67 48
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  27. 1 1
      Emby.Server.Implementations/Dto/DtoService.cs
  28. 8 6
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  29. 4 3
      Emby.Server.Implementations/HttpServer/Security/AuthService.cs
  30. 78 71
      Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
  31. 16 1
      Emby.Server.Implementations/Library/LibraryManager.cs
  32. 1 1
      Emby.Server.Implementations/Library/MediaSourceManager.cs
  33. 1 1
      Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs
  34. 8 2
      Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
  35. 2 2
      Emby.Server.Implementations/LiveTv/LiveTvManager.cs
  36. 1 1
      Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs
  37. 3 3
      Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
  38. 3 1
      Emby.Server.Implementations/Localization/Core/en-GB.json
  39. 4 2
      Emby.Server.Implementations/Localization/Core/es.json
  40. 23 6
      Emby.Server.Implementations/Localization/Core/fil.json
  41. 61 59
      Emby.Server.Implementations/Localization/Core/hr.json
  42. 3 1
      Emby.Server.Implementations/Localization/Core/hu.json
  43. 3 1
      Emby.Server.Implementations/Localization/Core/nl.json
  44. 3 1
      Emby.Server.Implementations/Localization/Core/ro.json
  45. 3 1
      Emby.Server.Implementations/Localization/Core/sr.json
  46. 3 1
      Emby.Server.Implementations/Localization/Core/ta.json
  47. 2 1
      Emby.Server.Implementations/Localization/Core/tr.json
  48. 3 1
      Emby.Server.Implementations/Localization/Core/vi.json
  49. 3 1
      Emby.Server.Implementations/Localization/Core/zh-CN.json
  50. 3 1
      Emby.Server.Implementations/Localization/Core/zh-TW.json
  51. 1 1
      Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
  52. 1 1
      Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
  53. 3 2
      Emby.Server.Implementations/Session/WebSocketController.cs
  54. 5 5
      Emby.Server.Implementations/Updates/InstallationManager.cs
  55. 7 0
      Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
  56. 7 8
      Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
  57. 5 3
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
  58. 5 3
      Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs
  59. 3 3
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
  60. 5 0
      Jellyfin.Api/Constants/InternalClaimTypes.cs
  61. 0 135
      Jellyfin.Api/Controllers/AlbumsController.cs
  62. 8 10
      Jellyfin.Api/Controllers/ArtistsController.cs
  63. 6 8
      Jellyfin.Api/Controllers/ChannelsController.cs
  64. 3 0
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  65. 26 0
      Jellyfin.Api/Controllers/DlnaServerController.cs
  66. 4 3
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  67. 6 0
      Jellyfin.Api/Controllers/EnvironmentController.cs
  68. 3 3
      Jellyfin.Api/Controllers/FilterController.cs
  69. 17 134
      Jellyfin.Api/Controllers/GenresController.cs
  70. 3 1
      Jellyfin.Api/Controllers/HlsSegmentController.cs
  71. 1 1
      Jellyfin.Api/Controllers/ImageByNameController.cs
  72. 2 1
      Jellyfin.Api/Controllers/ImageController.cs
  73. 29 35
      Jellyfin.Api/Controllers/InstantMixController.cs
  74. 10 2
      Jellyfin.Api/Controllers/ItemLookupController.cs
  75. 7 9
      Jellyfin.Api/Controllers/ItemsController.cs
  76. 68 98
      Jellyfin.Api/Controllers/LibraryController.cs
  77. 23 27
      Jellyfin.Api/Controllers/LiveTvController.cs
  78. 3 3
      Jellyfin.Api/Controllers/MoviesController.cs
  79. 12 138
      Jellyfin.Api/Controllers/MusicGenresController.cs
  80. 7 1
      Jellyfin.Api/Controllers/PackageController.cs
  81. 28 168
      Jellyfin.Api/Controllers/PersonsController.cs
  82. 5 5
      Jellyfin.Api/Controllers/PlaylistsController.cs
  83. 14 6
      Jellyfin.Api/Controllers/RemoteImageController.cs
  84. 1 1
      Jellyfin.Api/Controllers/SearchController.cs
  85. 9 133
      Jellyfin.Api/Controllers/StudiosController.cs
  86. 125 0
      Jellyfin.Api/Controllers/SubtitleController.cs
  87. 3 3
      Jellyfin.Api/Controllers/TrailersController.cs
  88. 15 18
      Jellyfin.Api/Controllers/TvShowsController.cs
  89. 5 5
      Jellyfin.Api/Controllers/UserLibraryController.cs
  90. 1 1
      Jellyfin.Api/Controllers/VideoAttachmentsController.cs
  91. 3 1
      Jellyfin.Api/Controllers/VideoHlsController.cs
  92. 5 5
      Jellyfin.Api/Controllers/YearsController.cs
  93. 6 40
      Jellyfin.Api/Extensions/DtoExtensions.cs
  94. 8 1
      Jellyfin.Api/Helpers/AudioHelper.cs
  95. 13 0
      Jellyfin.Api/Helpers/ClaimHelpers.cs
  96. 6 1
      Jellyfin.Api/Helpers/DynamicHlsHelper.cs
  97. 2 2
      Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
  98. 3 3
      Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
  99. 5 0
      Jellyfin.Api/Helpers/HlsHelpers.cs
  100. 6 0
      Jellyfin.Api/Helpers/ProgressiveFileCopier.cs

+ 1 - 1
.ci/azure-pipelines-abi.yml

@@ -7,7 +7,7 @@ parameters:
   default: "ubuntu-latest"
   default: "ubuntu-latest"
 - name: DotNetSdkVersion
 - name: DotNetSdkVersion
   type: string
   type: string
-  default: 3.1.100
+  default: 5.0.100
 
 
 jobs:
 jobs:
   - job: CompatibilityCheck
   - job: CompatibilityCheck

+ 0 - 19
.ci/azure-pipelines-api-client.yml

@@ -35,14 +35,6 @@ jobs:
         customEndpoint: 'jellyfin-bot for NPM'
         customEndpoint: 'jellyfin-bot for NPM'
 
 
 ## Generate npm api client
 ## Generate npm api client
-# Unstable
-    - task: CmdLine@2
-      displayName: 'Build unstable typescript axios client'
-      condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
-      inputs:
-        script: "bash ./apiclient/templates/typescript/axios/generate.sh $(System.ArtifactsDirectory) $(Build.BuildNumber)"
-
-# Stable
     - task: CmdLine@2
     - task: CmdLine@2
       displayName: 'Build stable typescript axios client'
       displayName: 'Build stable typescript axios client'
       condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
       condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
@@ -57,17 +49,6 @@ jobs:
         workingDir: ./apiclient/generated/typescript/axios
         workingDir: ./apiclient/generated/typescript/axios
 
 
 ## Publish npm packages
 ## Publish npm packages
-# Unstable
-    - task: Npm@1
-      displayName: 'Publish unstable typescript axios client'
-      condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
-      inputs:
-        command: publish
-        publishRegistry: useFeed
-        publishFeed: 'jellyfin/unstable'
-        workingDir: ./apiclient/generated/typescript/axios
-
-# Stable
     - task: Npm@1
     - task: Npm@1
       displayName: 'Publish stable typescript axios client'
       displayName: 'Publish stable typescript axios client'
       condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
       condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')

+ 1 - 1
.ci/azure-pipelines-main.yml

@@ -1,7 +1,7 @@
 parameters:
 parameters:
   LinuxImage: 'ubuntu-latest'
   LinuxImage: 'ubuntu-latest'
   RestoreBuildProjects: 'Jellyfin.Server/Jellyfin.Server.csproj'
   RestoreBuildProjects: 'Jellyfin.Server/Jellyfin.Server.csproj'
-  DotNetSdkVersion: 3.1.100
+  DotNetSdkVersion: 5.0.100
 
 
 jobs:
 jobs:
   - job: Build
   - job: Build

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

@@ -10,7 +10,7 @@ parameters:
   default: "tests/**/*Tests.csproj"
   default: "tests/**/*Tests.csproj"
 - name: DotNetSdkVersion
 - name: DotNetSdkVersion
   type: string
   type: string
-  default: 3.1.100
+  default: 5.0.100
 
 
 jobs:
 jobs:
   - job: Test
   - job: Test
@@ -94,5 +94,5 @@ jobs:
         displayName: 'Publish OpenAPI Artifact'
         displayName: 'Publish OpenAPI Artifact'
         condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
         condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
         inputs:
         inputs:
-          targetPath: "tests/Jellyfin.Api.Tests/bin/Release/netcoreapp3.1/openapi.json"
+          targetPath: "tests/Jellyfin.Api.Tests/bin/Release/net5.0/openapi.json"
           artifactName: 'OpenAPI Spec'
           artifactName: 'OpenAPI Spec'

+ 1 - 1
.ci/azure-pipelines.yml

@@ -6,7 +6,7 @@ variables:
 - name: RestoreBuildProjects
 - name: RestoreBuildProjects
   value: 'Jellyfin.Server/Jellyfin.Server.csproj'
   value: 'Jellyfin.Server/Jellyfin.Server.csproj'
 - name: DotNetSdkVersion
 - name: DotNetSdkVersion
-  value: 3.1.100
+  value: 5.0.100
 
 
 pr:
 pr:
   autoCancel: true
   autoCancel: true

+ 2 - 2
.vscode/launch.json

@@ -6,7 +6,7 @@
             "type": "coreclr",
             "type": "coreclr",
             "request": "launch",
             "request": "launch",
             "preLaunchTask": "build",
             "preLaunchTask": "build",
-            "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll",
+            "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net5.0/jellyfin.dll",
             "args": [],
             "args": [],
             "cwd": "${workspaceFolder}/Jellyfin.Server",
             "cwd": "${workspaceFolder}/Jellyfin.Server",
             "console": "internalConsole",
             "console": "internalConsole",
@@ -22,7 +22,7 @@
             "type": "coreclr",
             "type": "coreclr",
             "request": "launch",
             "request": "launch",
             "preLaunchTask": "build",
             "preLaunchTask": "build",
-            "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll",
+            "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net5.0/jellyfin.dll",
             "args": ["--nowebclient"],
             "args": ["--nowebclient"],
             "cwd": "${workspaceFolder}/Jellyfin.Server",
             "cwd": "${workspaceFolder}/Jellyfin.Server",
             "console": "internalConsole",
             "console": "internalConsole",

+ 2 - 2
Dockerfile

@@ -1,4 +1,4 @@
-ARG DOTNET_VERSION=3.1
+ARG DOTNET_VERSION=5.0
 
 
 FROM node:alpine as web-builder
 FROM node:alpine as web-builder
 ARG JELLYFIN_WEB_VERSION=master
 ARG JELLYFIN_WEB_VERSION=master
@@ -8,7 +8,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-
  && yarn install \
  && yarn install \
  && mv dist /dist
  && mv dist /dist
 
 
-FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION}-buster as builder
+FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim as builder
 WORKDIR /repo
 WORKDIR /repo
 COPY . .
 COPY . .
 ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
 ENV DOTNET_CLI_TELEMETRY_OPTOUT=1

+ 2 - 2
Dockerfile.arm

@@ -2,7 +2,7 @@
 #####################################
 #####################################
 # Requires binfm_misc registration
 # Requires binfm_misc registration
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
-ARG DOTNET_VERSION=3.1
+ARG DOTNET_VERSION=5.0
 
 
 
 
 FROM node:alpine as web-builder
 FROM node:alpine as web-builder
@@ -14,7 +14,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-
  && mv dist /dist
  && mv dist /dist
 
 
 
 
-FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION} as builder
+FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder
 WORKDIR /repo
 WORKDIR /repo
 COPY . .
 COPY . .
 ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
 ENV DOTNET_CLI_TELEMETRY_OPTOUT=1

+ 2 - 2
Dockerfile.arm64

@@ -2,7 +2,7 @@
 #####################################
 #####################################
 # Requires binfm_misc registration
 # Requires binfm_misc registration
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
 # https://github.com/multiarch/qemu-user-static#binfmt_misc-register
-ARG DOTNET_VERSION=3.1
+ARG DOTNET_VERSION=5.0
 
 
 
 
 FROM node:alpine as web-builder
 FROM node:alpine as web-builder
@@ -14,7 +14,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-
  && mv dist /dist
  && mv dist /dist
 
 
 
 
-FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION} as builder
+FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder
 WORKDIR /repo
 WORKDIR /repo
 COPY . .
 COPY . .
 ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
 ENV DOTNET_CLI_TELEMETRY_OPTOUT=1

+ 1 - 1
DvdLib/DvdLib.csproj

@@ -10,7 +10,7 @@
   </ItemGroup>
   </ItemGroup>
 
 
   <PropertyGroup>
   <PropertyGroup>
-    <TargetFramework>netstandard2.1</TargetFramework>
+    <TargetFramework>net5.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>

+ 1 - 1
DvdLib/Ifo/Dvd.cs

@@ -31,7 +31,7 @@ namespace DvdLib.Ifo
                         continue;
                         continue;
                     }
                     }
 
 
-                    var nums = ifo.Name.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries);
+                    var nums = ifo.Name.Split('_', StringSplitOptions.RemoveEmptyEntries);
                     if (nums.Length >= 2 && ushort.TryParse(nums[1], out var ifoNumber))
                     if (nums.Length >= 2 && ushort.TryParse(nums[1], out var ifoNumber))
                     {
                     {
                         ReadVTS(ifoNumber, ifo.FullName);
                         ReadVTS(ifoNumber, ifo.FullName);

+ 1 - 1
Emby.Dlna/Didl/Filter.cs

@@ -18,7 +18,7 @@ namespace Emby.Dlna.Didl
         {
         {
             _all = string.Equals(filter, "*", StringComparison.OrdinalIgnoreCase);
             _all = string.Equals(filter, "*", StringComparison.OrdinalIgnoreCase);
 
 
-            _fields = (filter ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
+            _fields = (filter ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries);
         }
         }
 
 
         public bool Contains(string field)
         public bool Contains(string field)

+ 1 - 1
Emby.Dlna/Emby.Dlna.csproj

@@ -17,7 +17,7 @@
   </ItemGroup>
   </ItemGroup>
 
 
   <PropertyGroup>
   <PropertyGroup>
-    <TargetFramework>netstandard2.1</TargetFramework>
+    <TargetFramework>net5.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>

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

@@ -83,7 +83,7 @@ namespace Emby.Dlna.Eventing
             if (!string.IsNullOrEmpty(header))
             if (!string.IsNullOrEmpty(header))
             {
             {
                 // Starts with SECOND-
                 // Starts with SECOND-
-                header = header.Split('-').Last();
+                header = header.Split('-')[^1];
 
 
                 if (int.TryParse(header, NumberStyles.Integer, _usCulture, out var val))
                 if (int.TryParse(header, NumberStyles.Integer, _usCulture, out var val))
                 {
                 {

+ 1 - 0
Emby.Dlna/Service/BaseControlHandler.cs

@@ -169,6 +169,7 @@ namespace Emby.Dlna.Service
                         var result = new ControlRequestInfo(localName, namespaceURI);
                         var result = new ControlRequestInfo(localName, namespaceURI);
                         using var subReader = reader.ReadSubtree();
                         using var subReader = reader.ReadSubtree();
                         await ParseFirstBodyChildAsync(subReader, result.Headers).ConfigureAwait(false);
                         await ParseFirstBodyChildAsync(subReader, result.Headers).ConfigureAwait(false);
+                        return result;
                     }
                     }
                     else
                     else
                     {
                     {

+ 1 - 1
Emby.Drawing/Emby.Drawing.csproj

@@ -6,7 +6,7 @@
   </PropertyGroup>
   </PropertyGroup>
 
 
   <PropertyGroup>
   <PropertyGroup>
-    <TargetFramework>netstandard2.1</TargetFramework>
+    <TargetFramework>net5.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>

+ 1 - 1
Emby.Naming/Emby.Naming.csproj

@@ -6,7 +6,7 @@
   </PropertyGroup>
   </PropertyGroup>
 
 
   <PropertyGroup>
   <PropertyGroup>
-    <TargetFramework>netstandard2.1</TargetFramework>
+    <TargetFramework>net5.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>

+ 5 - 0
Emby.Naming/Video/CleanDateTimeParser.cs

@@ -15,6 +15,11 @@ namespace Emby.Naming.Video
         public static CleanDateTimeResult Clean(string name, IReadOnlyList<Regex> cleanDateTimeRegexes)
         public static CleanDateTimeResult Clean(string name, IReadOnlyList<Regex> cleanDateTimeRegexes)
         {
         {
             CleanDateTimeResult result = new CleanDateTimeResult(name);
             CleanDateTimeResult result = new CleanDateTimeResult(name);
+            if (string.IsNullOrEmpty(name))
+            {
+                return result;
+            }
+
             var len = cleanDateTimeRegexes.Count;
             var len = cleanDateTimeRegexes.Count;
             for (int i = 0; i < len; i++)
             for (int i = 0; i < len; i++)
             {
             {

+ 1 - 1
Emby.Notifications/Emby.Notifications.csproj

@@ -6,7 +6,7 @@
   </PropertyGroup>
   </PropertyGroup>
 
 
   <PropertyGroup>
   <PropertyGroup>
-    <TargetFramework>netstandard2.1</TargetFramework>
+    <TargetFramework>net5.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>

+ 5 - 5
Emby.Notifications/NotificationEntryPoint.cs

@@ -83,7 +83,7 @@ namespace Emby.Notifications
             return Task.CompletedTask;
             return Task.CompletedTask;
         }
         }
 
 
-        private async void OnAppHostHasPendingRestartChanged(object sender, EventArgs e)
+        private async void OnAppHostHasPendingRestartChanged(object? sender, EventArgs e)
         {
         {
             var type = NotificationType.ServerRestartRequired.ToString();
             var type = NotificationType.ServerRestartRequired.ToString();
 
 
@@ -99,7 +99,7 @@ namespace Emby.Notifications
             await SendNotification(notification, null).ConfigureAwait(false);
             await SendNotification(notification, null).ConfigureAwait(false);
         }
         }
 
 
-        private async void OnActivityManagerEntryCreated(object sender, GenericEventArgs<ActivityLogEntry> e)
+        private async void OnActivityManagerEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e)
         {
         {
             var entry = e.Argument;
             var entry = e.Argument;
 
 
@@ -132,7 +132,7 @@ namespace Emby.Notifications
             return _config.GetConfiguration<NotificationOptions>("notifications");
             return _config.GetConfiguration<NotificationOptions>("notifications");
         }
         }
 
 
-        private async void OnAppHostHasUpdateAvailableChanged(object sender, EventArgs e)
+        private async void OnAppHostHasUpdateAvailableChanged(object? sender, EventArgs e)
         {
         {
             if (!_appHost.HasUpdateAvailable)
             if (!_appHost.HasUpdateAvailable)
             {
             {
@@ -151,7 +151,7 @@ namespace Emby.Notifications
             await SendNotification(notification, null).ConfigureAwait(false);
             await SendNotification(notification, null).ConfigureAwait(false);
         }
         }
 
 
-        private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs e)
+        private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e)
         {
         {
             if (!FilterItem(e.Item))
             if (!FilterItem(e.Item))
             {
             {
@@ -197,7 +197,7 @@ namespace Emby.Notifications
             return item.SourceType == SourceType.Library;
             return item.SourceType == SourceType.Library;
         }
         }
 
 
-        private async void LibraryUpdateTimerCallback(object state)
+        private async void LibraryUpdateTimerCallback(object? state)
         {
         {
             List<BaseItem> items;
             List<BaseItem> items;
 
 

+ 1 - 1
Emby.Photos/Emby.Photos.csproj

@@ -19,7 +19,7 @@
   </ItemGroup>
   </ItemGroup>
 
 
   <PropertyGroup>
   <PropertyGroup>
-    <TargetFramework>netstandard2.1</TargetFramework>
+    <TargetFramework>net5.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>

+ 27 - 0
Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs

@@ -133,6 +133,33 @@ namespace Emby.Server.Implementations.AppBase
             }
             }
         }
         }
 
 
+        /// <summary>
+        /// Manually pre-loads a factory so that it is available pre system initialisation.
+        /// </summary>
+        /// <typeparam name="T">Class to register.</typeparam>
+        public virtual void RegisterConfiguration<T>()
+            where T : IConfigurationFactory
+        {
+            IConfigurationFactory factory = Activator.CreateInstance<T>();
+
+            if (_configurationFactories == null)
+            {
+                _configurationFactories = new[] { factory };
+            }
+            else
+            {
+                var oldLen = _configurationFactories.Length;
+                var arr = new IConfigurationFactory[oldLen + 1];
+                _configurationFactories.CopyTo(arr, 0);
+                arr[oldLen] = factory;
+                _configurationFactories = arr;
+            }
+
+            _configurationStores = _configurationFactories
+                .SelectMany(i => i.GetConfigurations())
+                .ToArray();
+        }
+
         /// <summary>
         /// <summary>
         /// Adds parts.
         /// Adds parts.
         /// </summary>
         /// </summary>

+ 4 - 2
Emby.Server.Implementations/AppBase/ConfigurationHelper.cs

@@ -3,6 +3,7 @@
 using System;
 using System;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Serialization;
 
 
 namespace Emby.Server.Implementations.AppBase
 namespace Emby.Server.Implementations.AppBase
@@ -35,7 +36,7 @@ namespace Emby.Server.Implementations.AppBase
             }
             }
             catch (Exception)
             catch (Exception)
             {
             {
-                configuration = Activator.CreateInstance(type);
+                configuration = Activator.CreateInstance(type) ?? throw new ArgumentException($"Provided path ({type}) is not valid.", nameof(type));
             }
             }
 
 
             using var stream = new MemoryStream(buffer?.Length ?? 0);
             using var stream = new MemoryStream(buffer?.Length ?? 0);
@@ -48,8 +49,9 @@ namespace Emby.Server.Implementations.AppBase
             // If the file didn't exist before, or if something has changed, re-save
             // If the file didn't exist before, or if something has changed, re-save
             if (buffer == null || !newBytes.AsSpan(0, newBytesLen).SequenceEqual(buffer))
             if (buffer == null || !newBytes.AsSpan(0, newBytesLen).SequenceEqual(buffer))
             {
             {
-                Directory.CreateDirectory(Path.GetDirectoryName(path));
+                var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path));
 
 
+                Directory.CreateDirectory(directory);
                 // Save it after load in case we got new items
                 // Save it after load in case we got new items
                 using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
                 using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
                 {
                 {

+ 67 - 84
Emby.Server.Implementations/ApplicationHost.cs

@@ -4,7 +4,6 @@ using System;
 using System.Collections.Concurrent;
 using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Diagnostics;
 using System.Diagnostics;
-using System.Globalization;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
 using System.Net;
 using System.Net;
@@ -30,7 +29,6 @@ using Emby.Server.Implementations.Cryptography;
 using Emby.Server.Implementations.Data;
 using Emby.Server.Implementations.Data;
 using Emby.Server.Implementations.Devices;
 using Emby.Server.Implementations.Devices;
 using Emby.Server.Implementations.Dto;
 using Emby.Server.Implementations.Dto;
-using Emby.Server.Implementations.HttpServer;
 using Emby.Server.Implementations.HttpServer.Security;
 using Emby.Server.Implementations.HttpServer.Security;
 using Emby.Server.Implementations.IO;
 using Emby.Server.Implementations.IO;
 using Emby.Server.Implementations.Library;
 using Emby.Server.Implementations.Library;
@@ -128,7 +126,6 @@ namespace Emby.Server.Implementations
         private IMediaEncoder _mediaEncoder;
         private IMediaEncoder _mediaEncoder;
         private ISessionManager _sessionManager;
         private ISessionManager _sessionManager;
         private IHttpClientFactory _httpClientFactory;
         private IHttpClientFactory _httpClientFactory;
-
         private string[] _urlPrefixes;
         private string[] _urlPrefixes;
 
 
         /// <summary>
         /// <summary>
@@ -499,24 +496,11 @@ namespace Emby.Server.Implementations
                 HttpsPort = ServerConfiguration.DefaultHttpsPort;
                 HttpsPort = ServerConfiguration.DefaultHttpsPort;
             }
             }
 
 
-            if (Plugins != null)
-            {
-                var pluginBuilder = new StringBuilder();
-
-                foreach (var plugin in Plugins)
-                {
-                    pluginBuilder.Append(plugin.Name)
-                        .Append(' ')
-                        .Append(plugin.Version)
-                        .AppendLine();
-                }
-
-                Logger.LogInformation("Plugins: {Plugins}", pluginBuilder.ToString());
-            }
-
             DiscoverTypes();
             DiscoverTypes();
 
 
             RegisterServices();
             RegisterServices();
+
+            RegisterPluginServices();
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -781,10 +765,24 @@ namespace Emby.Server.Implementations
 
 
             ConfigurationManager.AddParts(GetExports<IConfigurationFactory>());
             ConfigurationManager.AddParts(GetExports<IConfigurationFactory>());
             _plugins = GetExports<IPlugin>()
             _plugins = GetExports<IPlugin>()
-                        .Select(LoadPlugin)
                         .Where(i => i != null)
                         .Where(i => i != null)
                         .ToArray();
                         .ToArray();
 
 
+            if (Plugins != null)
+            {
+                var pluginBuilder = new StringBuilder();
+
+                foreach (var plugin in Plugins)
+                {
+                    pluginBuilder.Append(plugin.Name)
+                        .Append(' ')
+                        .Append(plugin.Version)
+                        .AppendLine();
+                }
+
+                Logger.LogInformation("Plugins: {Plugins}", pluginBuilder.ToString());
+            }
+
             _urlPrefixes = GetUrlPrefixes().ToArray();
             _urlPrefixes = GetUrlPrefixes().ToArray();
 
 
             Resolve<ILibraryManager>().AddParts(
             Resolve<ILibraryManager>().AddParts(
@@ -814,21 +812,6 @@ namespace Emby.Server.Implementations
             Resolve<IIsoManager>().AddParts(GetExports<IIsoMounter>());
             Resolve<IIsoManager>().AddParts(GetExports<IIsoMounter>());
         }
         }
 
 
-        private IPlugin LoadPlugin(IPlugin plugin)
-        {
-            try
-            {
-                plugin.RegisterServices(ServiceCollection);
-            }
-            catch (Exception ex)
-            {
-                Logger.LogError(ex, "Error loading plugin {PluginName}", plugin.GetType().FullName);
-                return null;
-            }
-
-            return plugin;
-        }
-
         /// <summary>
         /// <summary>
         /// Discovers the types.
         /// Discovers the types.
         /// </summary>
         /// </summary>
@@ -839,6 +822,22 @@ namespace Emby.Server.Implementations
             _allConcreteTypes = GetTypes(GetComposablePartAssemblies()).ToArray();
             _allConcreteTypes = GetTypes(GetComposablePartAssemblies()).ToArray();
         }
         }
 
 
+        private void RegisterPluginServices()
+        {
+            foreach (var pluginServiceRegistrator in GetExportTypes<IPluginServiceRegistrator>())
+            {
+                try
+                {
+                    var instance = (IPluginServiceRegistrator)Activator.CreateInstance(pluginServiceRegistrator);
+                    instance.RegisterServices(ServiceCollection);
+                }
+                catch (Exception ex)
+                {
+                    Logger.LogError(ex, "Error registering plugin services from {Assembly}.", pluginServiceRegistrator.Assembly);
+                }
+            }
+        }
+
         private IEnumerable<Type> GetTypes(IEnumerable<Assembly> assemblies)
         private IEnumerable<Type> GetTypes(IEnumerable<Assembly> assemblies)
         {
         {
             foreach (var ass in assemblies)
             foreach (var ass in assemblies)
@@ -993,79 +992,59 @@ namespace Emby.Server.Implementations
 
 
         protected abstract void RestartInternal();
         protected abstract void RestartInternal();
 
 
-        /// <summary>
-        /// Comparison function used in <see cref="GetPlugins" />.
-        /// </summary>
-        /// <param name="a">Item to compare.</param>
-        /// <param name="b">Item to compare with.</param>
-        /// <returns>Boolean result of the operation.</returns>
-        private static int VersionCompare(
-            (Version PluginVersion, string Name, string Path) a,
-            (Version PluginVersion, string Name, string Path) b)
+        /// <inheritdoc/>
+        public IEnumerable<LocalPlugin> GetLocalPlugins(string path, bool cleanup = true)
         {
         {
-            int compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture);
-
-            if (compare == 0)
+            var minimumVersion = new Version(0, 0, 0, 1);
+            var versions = new List<LocalPlugin>();
+            if (!Directory.Exists(path))
             {
             {
-                return a.PluginVersion.CompareTo(b.PluginVersion);
+                // Plugin path doesn't exist, don't try to enumerate subfolders.
+                return Enumerable.Empty<LocalPlugin>();
             }
             }
 
 
-            return compare;
-        }
-
-        /// <summary>
-        /// Returns a list of plugins to install.
-        /// </summary>
-        /// <param name="path">Path to check.</param>
-        /// <param name="cleanup">True if an attempt should be made to delete old plugs.</param>
-        /// <returns>Enumerable list of dlls to load.</returns>
-        private IEnumerable<string> GetPlugins(string path, bool cleanup = true)
-        {
-            var dllList = new List<string>();
-            var versions = new List<(Version PluginVersion, string Name, string Path)>();
             var directories = Directory.EnumerateDirectories(path, "*.*", SearchOption.TopDirectoryOnly);
             var directories = Directory.EnumerateDirectories(path, "*.*", SearchOption.TopDirectoryOnly);
-            string metafile;
 
 
             foreach (var dir in directories)
             foreach (var dir in directories)
             {
             {
                 try
                 try
                 {
                 {
-                    metafile = Path.Combine(dir, "meta.json");
+                    var metafile = Path.Combine(dir, "meta.json");
                     if (File.Exists(metafile))
                     if (File.Exists(metafile))
                     {
                     {
                         var manifest = _jsonSerializer.DeserializeFromFile<PluginManifest>(metafile);
                         var manifest = _jsonSerializer.DeserializeFromFile<PluginManifest>(metafile);
 
 
                         if (!Version.TryParse(manifest.TargetAbi, out var targetAbi))
                         if (!Version.TryParse(manifest.TargetAbi, out var targetAbi))
                         {
                         {
-                            targetAbi = new Version(0, 0, 0, 1);
+                            targetAbi = minimumVersion;
                         }
                         }
 
 
                         if (!Version.TryParse(manifest.Version, out var version))
                         if (!Version.TryParse(manifest.Version, out var version))
                         {
                         {
-                            version = new Version(0, 0, 0, 1);
+                            version = minimumVersion;
                         }
                         }
 
 
                         if (ApplicationVersion >= targetAbi)
                         if (ApplicationVersion >= targetAbi)
                         {
                         {
                             // Only load Plugins if the plugin is built for this version or below.
                             // Only load Plugins if the plugin is built for this version or below.
-                            versions.Add((version, manifest.Name, dir));
+                            versions.Add(new LocalPlugin(manifest.Guid, manifest.Name, version, dir));
                         }
                         }
                     }
                     }
                     else
                     else
                     {
                     {
                         // No metafile, so lets see if the folder is versioned.
                         // No metafile, so lets see if the folder is versioned.
-                        metafile = dir.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries)[^1];
+                        metafile = dir.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)[^1];
 
 
                         int versionIndex = dir.LastIndexOf('_');
                         int versionIndex = dir.LastIndexOf('_');
-                        if (versionIndex != -1 && Version.TryParse(dir.Substring(versionIndex + 1), out Version ver))
+                        if (versionIndex != -1 && Version.TryParse(dir.Substring(versionIndex + 1), out Version parsedVersion))
                         {
                         {
                             // Versioned folder.
                             // Versioned folder.
-                            versions.Add((ver, metafile, dir));
+                            versions.Add(new LocalPlugin(Guid.Empty, metafile, parsedVersion, dir));
                         }
                         }
                         else
                         else
                         {
                         {
                             // Un-versioned folder - Add it under the path name and version 0.0.0.1.
                             // Un-versioned folder - Add it under the path name and version 0.0.0.1.
-                            versions.Add((new Version(0, 0, 0, 1), metafile, dir));
+                            versions.Add(new LocalPlugin(Guid.Empty, metafile, minimumVersion, dir));
                         }
                         }
                     }
                     }
                 }
                 }
@@ -1076,14 +1055,14 @@ namespace Emby.Server.Implementations
             }
             }
 
 
             string lastName = string.Empty;
             string lastName = string.Empty;
-            versions.Sort(VersionCompare);
+            versions.Sort(LocalPlugin.Compare);
             // Traverse backwards through the list.
             // Traverse backwards through the list.
             // The first item will be the latest version.
             // The first item will be the latest version.
             for (int x = versions.Count - 1; x >= 0; x--)
             for (int x = versions.Count - 1; x >= 0; x--)
             {
             {
                 if (!string.Equals(lastName, versions[x].Name, StringComparison.OrdinalIgnoreCase))
                 if (!string.Equals(lastName, versions[x].Name, StringComparison.OrdinalIgnoreCase))
                 {
                 {
-                    dllList.AddRange(Directory.EnumerateFiles(versions[x].Path, "*.dll", SearchOption.AllDirectories));
+                    versions[x].DllFiles.AddRange(Directory.EnumerateFiles(versions[x].Path, "*.dll", SearchOption.AllDirectories));
                     lastName = versions[x].Name;
                     lastName = versions[x].Name;
                     continue;
                     continue;
                 }
                 }
@@ -1091,6 +1070,7 @@ namespace Emby.Server.Implementations
                 if (!string.IsNullOrEmpty(lastName) && cleanup)
                 if (!string.IsNullOrEmpty(lastName) && cleanup)
                 {
                 {
                     // Attempt a cleanup of old folders.
                     // Attempt a cleanup of old folders.
+                    versions.RemoveAt(x);
                     try
                     try
                     {
                     {
                         Logger.LogDebug("Deleting {Path}", versions[x].Path);
                         Logger.LogDebug("Deleting {Path}", versions[x].Path);
@@ -1103,7 +1083,7 @@ namespace Emby.Server.Implementations
                 }
                 }
             }
             }
 
 
-            return dllList;
+            return versions;
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -1114,21 +1094,24 @@ namespace Emby.Server.Implementations
         {
         {
             if (Directory.Exists(ApplicationPaths.PluginsPath))
             if (Directory.Exists(ApplicationPaths.PluginsPath))
             {
             {
-                foreach (var file in GetPlugins(ApplicationPaths.PluginsPath))
+                foreach (var plugin in GetLocalPlugins(ApplicationPaths.PluginsPath))
                 {
                 {
-                    Assembly plugAss;
-                    try
+                    foreach (var file in plugin.DllFiles)
                     {
                     {
-                        plugAss = Assembly.LoadFrom(file);
-                    }
-                    catch (FileLoadException ex)
-                    {
-                        Logger.LogError(ex, "Failed to load assembly {Path}", file);
-                        continue;
-                    }
+                        Assembly plugAss;
+                        try
+                        {
+                            plugAss = Assembly.LoadFrom(file);
+                        }
+                        catch (FileLoadException ex)
+                        {
+                            Logger.LogError(ex, "Failed to load assembly {Path}", file);
+                            continue;
+                        }
 
 
-                    Logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugAss.FullName, file);
-                    yield return plugAss;
+                        Logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugAss.FullName, file);
+                        yield return plugAss;
+                    }
                 }
                 }
             }
             }
 
 

+ 2 - 1
Emby.Server.Implementations/Cryptography/CryptographyProvider.cs

@@ -3,6 +3,7 @@
 using System;
 using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Security.Cryptography;
 using System.Security.Cryptography;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Model.Cryptography;
 using MediaBrowser.Model.Cryptography;
 using static MediaBrowser.Common.Cryptography.Constants;
 using static MediaBrowser.Common.Cryptography.Constants;
 
 
@@ -80,7 +81,7 @@ namespace Emby.Server.Implementations.Cryptography
                 throw new CryptographicException($"Requested hash method is not supported: {hashMethod}");
                 throw new CryptographicException($"Requested hash method is not supported: {hashMethod}");
             }
             }
 
 
-            using var h = HashAlgorithm.Create(hashMethod);
+            using var h = HashAlgorithm.Create(hashMethod) ?? throw new ResourceNotFoundException($"Unknown hash method: {hashMethod}.");
             if (salt.Length == 0)
             if (salt.Length == 0)
             {
             {
                 return h.ComputeHash(bytes);
                 return h.ComputeHash(bytes);

+ 67 - 48
Emby.Server.Implementations/Data/SqliteItemRepository.cs

@@ -1007,7 +1007,7 @@ namespace Emby.Server.Implementations.Data
                 return;
                 return;
             }
             }
 
 
-            var parts = value.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
+            var parts = value.Split('|', StringSplitOptions.RemoveEmptyEntries);
 
 
             foreach (var part in parts)
             foreach (var part in parts)
             {
             {
@@ -1057,7 +1057,7 @@ namespace Emby.Server.Implementations.Data
                 return;
                 return;
             }
             }
 
 
-            var parts = value.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
+            var parts = value.Split('|' , StringSplitOptions.RemoveEmptyEntries);
             var list = new List<ItemImageInfo>();
             var list = new List<ItemImageInfo>();
             foreach (var part in parts)
             foreach (var part in parts)
             {
             {
@@ -1096,7 +1096,7 @@ namespace Emby.Server.Implementations.Data
 
 
         public ItemImageInfo ItemImageInfoFromValueString(string value)
         public ItemImageInfo ItemImageInfoFromValueString(string value)
         {
         {
-            var parts = value.Split(new[] { '*' }, StringSplitOptions.None);
+            var parts = value.Split('*', StringSplitOptions.None);
 
 
             if (parts.Length < 3)
             if (parts.Length < 3)
             {
             {
@@ -1532,7 +1532,7 @@ namespace Emby.Server.Implementations.Data
             {
             {
                 if (!reader.IsDBNull(index))
                 if (!reader.IsDBNull(index))
                 {
                 {
-                    item.Genres = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
+                    item.Genres = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries);
                 }
                 }
 
 
                 index++;
                 index++;
@@ -1593,7 +1593,7 @@ namespace Emby.Server.Implementations.Data
                 {
                 {
                     IEnumerable<MetadataField> GetLockedFields(string s)
                     IEnumerable<MetadataField> GetLockedFields(string s)
                     {
                     {
-                        foreach (var i in s.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries))
+                        foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries))
                         {
                         {
                             if (Enum.TryParse(i, true, out MetadataField parsedValue))
                             if (Enum.TryParse(i, true, out MetadataField parsedValue))
                             {
                             {
@@ -1612,7 +1612,7 @@ namespace Emby.Server.Implementations.Data
             {
             {
                 if (!reader.IsDBNull(index))
                 if (!reader.IsDBNull(index))
                 {
                 {
-                    item.Studios = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
+                    item.Studios = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries);
                 }
                 }
 
 
                 index++;
                 index++;
@@ -1622,7 +1622,7 @@ namespace Emby.Server.Implementations.Data
             {
             {
                 if (!reader.IsDBNull(index))
                 if (!reader.IsDBNull(index))
                 {
                 {
-                    item.Tags = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
+                    item.Tags = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries);
                 }
                 }
 
 
                 index++;
                 index++;
@@ -1636,7 +1636,7 @@ namespace Emby.Server.Implementations.Data
                     {
                     {
                         IEnumerable<TrailerType> GetTrailerTypes(string s)
                         IEnumerable<TrailerType> GetTrailerTypes(string s)
                         {
                         {
-                            foreach (var i in s.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries))
+                            foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries))
                             {
                             {
                                 if (Enum.TryParse(i, true, out TrailerType parsedValue))
                                 if (Enum.TryParse(i, true, out TrailerType parsedValue))
                                 {
                                 {
@@ -1811,7 +1811,7 @@ namespace Emby.Server.Implementations.Data
             {
             {
                 if (!reader.IsDBNull(index))
                 if (!reader.IsDBNull(index))
                 {
                 {
-                    item.ProductionLocations = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries).ToArray();
+                    item.ProductionLocations = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries).ToArray();
                 }
                 }
 
 
                 index++;
                 index++;
@@ -1848,14 +1848,14 @@ namespace Emby.Server.Implementations.Data
             {
             {
                 if (item is IHasArtist hasArtists && !reader.IsDBNull(index))
                 if (item is IHasArtist hasArtists && !reader.IsDBNull(index))
                 {
                 {
-                    hasArtists.Artists = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
+                    hasArtists.Artists = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries);
                 }
                 }
 
 
                 index++;
                 index++;
 
 
                 if (item is IHasAlbumArtist hasAlbumArtists && !reader.IsDBNull(index))
                 if (item is IHasAlbumArtist hasAlbumArtists && !reader.IsDBNull(index))
                 {
                 {
-                    hasAlbumArtists.AlbumArtists = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
+                    hasAlbumArtists.AlbumArtists = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries);
                 }
                 }
 
 
                 index++;
                 index++;
@@ -2403,11 +2403,11 @@ namespace Emby.Server.Implementations.Data
 
 
                 if (string.IsNullOrEmpty(item.OfficialRating))
                 if (string.IsNullOrEmpty(item.OfficialRating))
                 {
                 {
-                    builder.Append("((OfficialRating is null) * 10)");
+                    builder.Append("(OfficialRating is null * 10)");
                 }
                 }
                 else
                 else
                 {
                 {
-                    builder.Append("((OfficialRating=@ItemOfficialRating) * 10)");
+                    builder.Append("(OfficialRating=@ItemOfficialRating * 10)");
                 }
                 }
 
 
                 if (item.ProductionYear.HasValue)
                 if (item.ProductionYear.HasValue)
@@ -2416,8 +2416,26 @@ namespace Emby.Server.Implementations.Data
                     builder.Append("+(Select Case When Abs(COALESCE(ProductionYear, 0) - @ItemProductionYear) < 5 Then 5 Else 0 End )");
                     builder.Append("+(Select Case When Abs(COALESCE(ProductionYear, 0) - @ItemProductionYear) < 5 Then 5 Else 0 End )");
                 }
                 }
 
 
-                //// genres, tags
-                builder.Append("+ ((Select count(CleanValue) from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from itemvalues where ItemId=@SimilarItemId)) * 10)");
+                // genres, tags, studios, person, year?
+                builder.Append("+ (Select count(1) * 10 from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from itemvalues where ItemId=@SimilarItemId))");
+
+                if (item is MusicArtist)
+                {
+                    // Match albums where the artist is AlbumArtist against other albums.
+                    // It is assumed that similar albums => similar artists.
+                    builder.Append(
+                        @"+ (WITH artistValues AS (
+	                            SELECT DISTINCT albumValues.CleanValue
+	                            FROM ItemValues albumValues
+	                            INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId
+	                            INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = @SimilarItemId
+                            ), similarArtist AS (
+	                            SELECT albumValues.ItemId
+	                            FROM ItemValues albumValues
+	                            INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId
+	                            INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = A.Guid
+                            ) SELECT COUNT(DISTINCT(CleanValue)) * 10 FROM ItemValues WHERE ItemId IN (SELECT ItemId FROM similarArtist) AND CleanValue IN (SELECT CleanValue FROM artistValues))");
+                }
 
 
                 builder.Append(") as SimilarityScore");
                 builder.Append(") as SimilarityScore");
 
 
@@ -5002,26 +5020,33 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
 
 
             CheckDisposed();
             CheckDisposed();
 
 
-            var commandText = "select Distinct Name from People";
+            var commandText = new StringBuilder("select Distinct p.Name from People p");
+
+            if (query.User != null && query.IsFavorite.HasValue)
+            {
+                commandText.Append(" LEFT JOIN TypedBaseItems tbi ON tbi.Name=p.Name AND tbi.Type='");
+                commandText.Append(typeof(Person).FullName);
+                commandText.Append("' LEFT JOIN UserDatas ON tbi.UserDataKey=key AND userId=@UserId");
+            }
 
 
             var whereClauses = GetPeopleWhereClauses(query, null);
             var whereClauses = GetPeopleWhereClauses(query, null);
 
 
             if (whereClauses.Count != 0)
             if (whereClauses.Count != 0)
             {
             {
-                commandText += "  where " + string.Join(" AND ", whereClauses);
+                commandText.Append(" where ").Append(string.Join(" AND ", whereClauses));
             }
             }
 
 
-            commandText += " order by ListOrder";
+            commandText.Append(" order by ListOrder");
 
 
             if (query.Limit > 0)
             if (query.Limit > 0)
             {
             {
-                commandText += " LIMIT " + query.Limit;
+                commandText.Append(" LIMIT ").Append(query.Limit);
             }
             }
 
 
             using (var connection = GetConnection(true))
             using (var connection = GetConnection(true))
             {
             {
                 var list = new List<string>();
                 var list = new List<string>();
-                using (var statement = PrepareStatement(connection, commandText))
+                using (var statement = PrepareStatement(connection, commandText.ToString()))
                 {
                 {
                     // Run this again to bind the params
                     // Run this again to bind the params
                     GetPeopleWhereClauses(query, statement);
                     GetPeopleWhereClauses(query, statement);
@@ -5045,7 +5070,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
 
 
             CheckDisposed();
             CheckDisposed();
 
 
-            var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People";
+            var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People p";
 
 
             var whereClauses = GetPeopleWhereClauses(query, null);
             var whereClauses = GetPeopleWhereClauses(query, null);
 
 
@@ -5087,19 +5112,13 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             if (!query.ItemId.Equals(Guid.Empty))
             if (!query.ItemId.Equals(Guid.Empty))
             {
             {
                 whereClauses.Add("ItemId=@ItemId");
                 whereClauses.Add("ItemId=@ItemId");
-                if (statement != null)
-                {
-                    statement.TryBind("@ItemId", query.ItemId.ToByteArray());
-                }
+                statement?.TryBind("@ItemId", query.ItemId.ToByteArray());
             }
             }
 
 
             if (!query.AppearsInItemId.Equals(Guid.Empty))
             if (!query.AppearsInItemId.Equals(Guid.Empty))
             {
             {
-                whereClauses.Add("Name in (Select Name from People where ItemId=@AppearsInItemId)");
-                if (statement != null)
-                {
-                    statement.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray());
-                }
+                whereClauses.Add("p.Name in (Select Name from People where ItemId=@AppearsInItemId)");
+                statement?.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray());
             }
             }
 
 
             var queryPersonTypes = query.PersonTypes.Where(IsValidPersonType).ToList();
             var queryPersonTypes = query.PersonTypes.Where(IsValidPersonType).ToList();
@@ -5107,10 +5126,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             if (queryPersonTypes.Count == 1)
             if (queryPersonTypes.Count == 1)
             {
             {
                 whereClauses.Add("PersonType=@PersonType");
                 whereClauses.Add("PersonType=@PersonType");
-                if (statement != null)
-                {
-                    statement.TryBind("@PersonType", queryPersonTypes[0]);
-                }
+                statement?.TryBind("@PersonType", queryPersonTypes[0]);
             }
             }
             else if (queryPersonTypes.Count > 1)
             else if (queryPersonTypes.Count > 1)
             {
             {
@@ -5124,10 +5140,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             if (queryExcludePersonTypes.Count == 1)
             if (queryExcludePersonTypes.Count == 1)
             {
             {
                 whereClauses.Add("PersonType<>@PersonType");
                 whereClauses.Add("PersonType<>@PersonType");
-                if (statement != null)
-                {
-                    statement.TryBind("@PersonType", queryExcludePersonTypes[0]);
-                }
+                statement?.TryBind("@PersonType", queryExcludePersonTypes[0]);
             }
             }
             else if (queryExcludePersonTypes.Count > 1)
             else if (queryExcludePersonTypes.Count > 1)
             {
             {
@@ -5139,19 +5152,24 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             if (query.MaxListOrder.HasValue)
             if (query.MaxListOrder.HasValue)
             {
             {
                 whereClauses.Add("ListOrder<=@MaxListOrder");
                 whereClauses.Add("ListOrder<=@MaxListOrder");
-                if (statement != null)
-                {
-                    statement.TryBind("@MaxListOrder", query.MaxListOrder.Value);
-                }
+                statement?.TryBind("@MaxListOrder", query.MaxListOrder.Value);
             }
             }
 
 
             if (!string.IsNullOrWhiteSpace(query.NameContains))
             if (!string.IsNullOrWhiteSpace(query.NameContains))
             {
             {
-                whereClauses.Add("Name like @NameContains");
-                if (statement != null)
-                {
-                    statement.TryBind("@NameContains", "%" + query.NameContains + "%");
-                }
+                whereClauses.Add("p.Name like @NameContains");
+                statement?.TryBind("@NameContains", "%" + query.NameContains + "%");
+            }
+
+            if (query.IsFavorite.HasValue)
+            {
+                whereClauses.Add("isFavorite=@IsFavorite");
+                statement?.TryBind("@IsFavorite", query.IsFavorite.Value);
+            }
+
+            if (query.User != null)
+            {
+                statement?.TryBind("@UserId", query.User.InternalId);
             }
             }
 
 
             return whereClauses;
             return whereClauses;
@@ -5420,6 +5438,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                 NameStartsWithOrGreater = query.NameStartsWithOrGreater,
                 NameStartsWithOrGreater = query.NameStartsWithOrGreater,
                 Tags = query.Tags,
                 Tags = query.Tags,
                 OfficialRatings = query.OfficialRatings,
                 OfficialRatings = query.OfficialRatings,
+                StudioIds = query.StudioIds,
                 GenreIds = query.GenreIds,
                 GenreIds = query.GenreIds,
                 Genres = query.Genres,
                 Genres = query.Genres,
                 Years = query.Years,
                 Years = query.Years,
@@ -5592,7 +5611,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                 return counts;
                 return counts;
             }
             }
 
 
-            var allTypes = typeString.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
+            var allTypes = typeString.Split('|', StringSplitOptions.RemoveEmptyEntries)
                 .ToLookup(x => x);
                 .ToLookup(x => x);
 
 
             foreach (var type in allTypes)
             foreach (var type in allTypes)

+ 1 - 1
Emby.Server.Implementations/Dto/DtoService.cs

@@ -275,7 +275,7 @@ namespace Emby.Server.Implementations.Dto
                     continue;
                     continue;
                 }
                 }
 
 
-                var containers = container.Split(new[] { ',' });
+                var containers = container.Split(',');
                 if (containers.Length < 2)
                 if (containers.Length < 2)
                 {
                 {
                     continue;
                     continue;

+ 8 - 6
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -32,13 +32,13 @@
     <PackageReference Include="Microsoft.AspNetCore.ResponseCompression" Version="2.2.0" />
     <PackageReference Include="Microsoft.AspNetCore.ResponseCompression" Version="2.2.0" />
     <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" />
     <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" />
     <PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="2.2.1" />
     <PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="2.2.1" />
-    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.9" />
-    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.9" />
-    <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.9" />
-    <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="3.1.9" />
+    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.0" />
+    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="5.0.0" />
+    <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
+    <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" />
     <PackageReference Include="Mono.Nat" Version="3.0.0" />
     <PackageReference Include="Mono.Nat" Version="3.0.0" />
     <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.4.0" />
     <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.4.0" />
-    <PackageReference Include="ServiceStack.Text.Core" Version="5.9.2" />
+    <PackageReference Include="ServiceStack.Text.Core" Version="5.10.0" />
     <PackageReference Include="sharpcompress" Version="0.26.0" />
     <PackageReference Include="sharpcompress" Version="0.26.0" />
     <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" />
     <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" />
     <PackageReference Include="DotNet.Glob" Version="3.1.0" />
     <PackageReference Include="DotNet.Glob" Version="3.1.0" />
@@ -49,10 +49,12 @@
   </ItemGroup>
   </ItemGroup>
 
 
   <PropertyGroup>
   <PropertyGroup>
-    <TargetFramework>netstandard2.1</TargetFramework>
+    <TargetFramework>net5.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors>
     <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors>
+    <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 -->
+    <NoWarn>AD0001</NoWarn>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <!-- Code Analyzers-->
   <!-- Code Analyzers-->

+ 4 - 3
Emby.Server.Implementations/HttpServer/Security/AuthService.cs

@@ -1,6 +1,7 @@
 #pragma warning disable CS1591
 #pragma warning disable CS1591
 
 
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
 
 
@@ -19,12 +20,12 @@ namespace Emby.Server.Implementations.HttpServer.Security
         public AuthorizationInfo Authenticate(HttpRequest request)
         public AuthorizationInfo Authenticate(HttpRequest request)
         {
         {
             var auth = _authorizationContext.GetAuthorizationInfo(request);
             var auth = _authorizationContext.GetAuthorizationInfo(request);
-            if (auth?.User == null)
+            if (!auth.IsAuthenticated)
             {
             {
-                return null;
+                throw new AuthenticationException("Invalid token.");
             }
             }
 
 
-            if (auth.User.HasPermission(PermissionKind.IsDisabled))
+            if (auth.User?.HasPermission(PermissionKind.IsDisabled) ?? false)
             {
             {
                 throw new SecurityException("User account has been disabled.");
                 throw new SecurityException("User account has been disabled.");
             }
             }

+ 78 - 71
Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs

@@ -36,8 +36,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
         public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext)
         public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext)
         {
         {
             var auth = GetAuthorizationDictionary(requestContext);
             var auth = GetAuthorizationDictionary(requestContext);
-            var (authInfo, _) =
-                GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query);
+            var authInfo = GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query);
             return authInfo;
             return authInfo;
         }
         }
 
 
@@ -49,19 +48,13 @@ namespace Emby.Server.Implementations.HttpServer.Security
         private AuthorizationInfo GetAuthorization(HttpContext httpReq)
         private AuthorizationInfo GetAuthorization(HttpContext httpReq)
         {
         {
             var auth = GetAuthorizationDictionary(httpReq);
             var auth = GetAuthorizationDictionary(httpReq);
-            var (authInfo, originalAuthInfo) =
-                GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query);
-
-            if (originalAuthInfo != null)
-            {
-                httpReq.Request.HttpContext.Items["OriginalAuthenticationInfo"] = originalAuthInfo;
-            }
+            var authInfo = GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query);
 
 
             httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo;
             httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo;
             return authInfo;
             return authInfo;
         }
         }
 
 
-        private (AuthorizationInfo authInfo, AuthenticationInfo originalAuthenticationInfo) GetAuthorizationInfoFromDictionary(
+        private AuthorizationInfo GetAuthorizationInfoFromDictionary(
             in Dictionary<string, string> auth,
             in Dictionary<string, string> auth,
             in IHeaderDictionary headers,
             in IHeaderDictionary headers,
             in IQueryCollection queryString)
             in IQueryCollection queryString)
@@ -108,88 +101,102 @@ namespace Emby.Server.Implementations.HttpServer.Security
                 Device = device,
                 Device = device,
                 DeviceId = deviceId,
                 DeviceId = deviceId,
                 Version = version,
                 Version = version,
-                Token = token
+                Token = token,
+                IsAuthenticated = false
             };
             };
 
 
-            AuthenticationInfo originalAuthenticationInfo = null;
-            if (!string.IsNullOrWhiteSpace(token))
+            if (string.IsNullOrWhiteSpace(token))
             {
             {
-                var result = _authRepo.Get(new AuthenticationInfoQuery
-                {
-                    AccessToken = token
-                });
+                // Request doesn't contain a token.
+                return authInfo;
+            }
 
 
-                originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null;
+            var result = _authRepo.Get(new AuthenticationInfoQuery
+            {
+                AccessToken = token
+            });
 
 
-                if (originalAuthenticationInfo != null)
-                {
-                    var updateToken = false;
+            if (result.Items.Count > 0)
+            {
+                authInfo.IsAuthenticated = true;
+            }
 
 
-                    // TODO: Remove these checks for IsNullOrWhiteSpace
-                    if (string.IsNullOrWhiteSpace(authInfo.Client))
-                    {
-                        authInfo.Client = originalAuthenticationInfo.AppName;
-                    }
+            var originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null;
 
 
-                    if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
-                    {
-                        authInfo.DeviceId = originalAuthenticationInfo.DeviceId;
-                    }
+            if (originalAuthenticationInfo != null)
+            {
+                var updateToken = false;
 
 
-                    // Temporary. TODO - allow clients to specify that the token has been shared with a casting device
-                    var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1;
+                // TODO: Remove these checks for IsNullOrWhiteSpace
+                if (string.IsNullOrWhiteSpace(authInfo.Client))
+                {
+                    authInfo.Client = originalAuthenticationInfo.AppName;
+                }
 
 
-                    if (string.IsNullOrWhiteSpace(authInfo.Device))
-                    {
-                        authInfo.Device = originalAuthenticationInfo.DeviceName;
-                    }
-                    else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase))
-                    {
-                        if (allowTokenInfoUpdate)
-                        {
-                            updateToken = true;
-                            originalAuthenticationInfo.DeviceName = authInfo.Device;
-                        }
-                    }
+                if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
+                {
+                    authInfo.DeviceId = originalAuthenticationInfo.DeviceId;
+                }
 
 
-                    if (string.IsNullOrWhiteSpace(authInfo.Version))
-                    {
-                        authInfo.Version = originalAuthenticationInfo.AppVersion;
-                    }
-                    else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase))
+                // Temporary. TODO - allow clients to specify that the token has been shared with a casting device
+                var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1;
+
+                if (string.IsNullOrWhiteSpace(authInfo.Device))
+                {
+                    authInfo.Device = originalAuthenticationInfo.DeviceName;
+                }
+                else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase))
+                {
+                    if (allowTokenInfoUpdate)
                     {
                     {
-                        if (allowTokenInfoUpdate)
-                        {
-                            updateToken = true;
-                            originalAuthenticationInfo.AppVersion = authInfo.Version;
-                        }
+                        updateToken = true;
+                        originalAuthenticationInfo.DeviceName = authInfo.Device;
                     }
                     }
+                }
 
 
-                    if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3)
+                if (string.IsNullOrWhiteSpace(authInfo.Version))
+                {
+                    authInfo.Version = originalAuthenticationInfo.AppVersion;
+                }
+                else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase))
+                {
+                    if (allowTokenInfoUpdate)
                     {
                     {
-                        originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow;
                         updateToken = true;
                         updateToken = true;
+                        originalAuthenticationInfo.AppVersion = authInfo.Version;
                     }
                     }
+                }
 
 
-                    if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty))
-                    {
-                        authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId);
+                if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3)
+                {
+                    originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow;
+                    updateToken = true;
+                }
 
 
-                        if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase))
-                        {
-                            originalAuthenticationInfo.UserName = authInfo.User.Username;
-                            updateToken = true;
-                        }
-                    }
+                if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty))
+                {
+                    authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId);
 
 
-                    if (updateToken)
+                    if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase))
                     {
                     {
-                        _authRepo.Update(originalAuthenticationInfo);
+                        originalAuthenticationInfo.UserName = authInfo.User.Username;
+                        updateToken = true;
                     }
                     }
+
+                    authInfo.IsApiKey = true;
+                }
+                else
+                {
+                    authInfo.IsApiKey = false;
+                }
+
+                if (updateToken)
+                {
+                    _authRepo.Update(originalAuthenticationInfo);
                 }
                 }
             }
             }
 
 
-            return (authInfo, originalAuthenticationInfo);
+            return authInfo;
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -238,7 +245,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
                 return null;
                 return null;
             }
             }
 
 
-            var parts = authorizationHeader.Split(new[] { ' ' }, 2);
+            var parts = authorizationHeader.Split(' ', 2);
 
 
             // There should be at least to parts
             // There should be at least to parts
             if (parts.Length != 2)
             if (parts.Length != 2)
@@ -262,11 +269,11 @@ namespace Emby.Server.Implementations.HttpServer.Security
 
 
             foreach (var item in parts)
             foreach (var item in parts)
             {
             {
-                var param = item.Trim().Split(new[] { '=' }, 2);
+                var param = item.Trim().Split('=', 2);
 
 
                 if (param.Length == 2)
                 if (param.Length == 2)
                 {
                 {
-                    var value = NormalizeValue(param[1].Trim(new[] { '"' }));
+                    var value = NormalizeValue(param[1].Trim('"'));
                     result[param[0]] = value;
                     result[param[0]] = value;
                 }
                 }
             }
             }

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

@@ -2440,6 +2440,21 @@ namespace Emby.Server.Implementations.Library
             new SubtitleResolver(BaseItem.LocalizationManager).AddExternalSubtitleStreams(streams, videoPath, streams.Count, files);
             new SubtitleResolver(BaseItem.LocalizationManager).AddExternalSubtitleStreams(streams, videoPath, streams.Count, files);
         }
         }
 
 
+        public BaseItem GetParentItem(string parentId, Guid? userId)
+        {
+            if (!string.IsNullOrEmpty(parentId))
+            {
+                return GetItemById(new Guid(parentId));
+            }
+
+            if (userId.HasValue && userId != Guid.Empty)
+            {
+                return GetUserRootFolder();
+            }
+
+            return RootFolder;
+        }
+
         /// <inheritdoc />
         /// <inheritdoc />
         public bool IsVideoFile(string path)
         public bool IsVideoFile(string path)
         {
         {
@@ -2690,7 +2705,7 @@ namespace Emby.Server.Implementations.Library
 
 
             var videos = videoListResolver.Resolve(fileSystemChildren);
             var videos = videoListResolver.Resolve(fileSystemChildren);
 
 
-            var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files.First().Path, StringComparison.OrdinalIgnoreCase));
+            var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase));
 
 
             if (currentVideo != null)
             if (currentVideo != null)
             {
             {

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

@@ -849,7 +849,7 @@ namespace Emby.Server.Implementations.Library
                 throw new ArgumentException("Key can't be empty.", nameof(key));
                 throw new ArgumentException("Key can't be empty.", nameof(key));
             }
             }
 
 
-            var keys = key.Split(new[] { LiveStreamIdDelimeter }, 2);
+            var keys = key.Split(LiveStreamIdDelimeter, 2);
 
 
             var provider = _providers.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), keys[0], StringComparison.OrdinalIgnoreCase));
             var provider = _providers.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), keys[0], StringComparison.OrdinalIgnoreCase));
 
 

+ 1 - 1
Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs

@@ -201,7 +201,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
                     continue;
                     continue;
                 }
                 }
 
 
-                var firstMedia = resolvedItem.Files.First();
+                var firstMedia = resolvedItem.Files[0];
 
 
                 var libraryItem = new T
                 var libraryItem = new T
                 {
                 {

+ 8 - 2
Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs

@@ -15,6 +15,7 @@ using System.Threading.Tasks;
 using MediaBrowser.Common;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Cryptography;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.LiveTv;
 using MediaBrowser.Model.LiveTv;
@@ -33,17 +34,20 @@ namespace Emby.Server.Implementations.LiveTv.Listings
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1);
         private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1);
         private readonly IApplicationHost _appHost;
         private readonly IApplicationHost _appHost;
+        private readonly ICryptoProvider _cryptoProvider;
 
 
         public SchedulesDirect(
         public SchedulesDirect(
             ILogger<SchedulesDirect> logger,
             ILogger<SchedulesDirect> logger,
             IJsonSerializer jsonSerializer,
             IJsonSerializer jsonSerializer,
             IHttpClientFactory httpClientFactory,
             IHttpClientFactory httpClientFactory,
-            IApplicationHost appHost)
+            IApplicationHost appHost,
+            ICryptoProvider cryptoProvider)
         {
         {
             _logger = logger;
             _logger = logger;
             _jsonSerializer = jsonSerializer;
             _jsonSerializer = jsonSerializer;
             _httpClientFactory = httpClientFactory;
             _httpClientFactory = httpClientFactory;
             _appHost = appHost;
             _appHost = appHost;
+            _cryptoProvider = cryptoProvider;
         }
         }
 
 
         private string UserAgent => _appHost.ApplicationUserAgent;
         private string UserAgent => _appHost.ApplicationUserAgent;
@@ -642,7 +646,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             CancellationToken cancellationToken)
             CancellationToken cancellationToken)
         {
         {
             using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
             using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
-            options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
+            var hashedPasswordBytes = _cryptoProvider.ComputeHash("SHA1", Encoding.ASCII.GetBytes(password), Array.Empty<byte>());
+            string hashedPassword = Hex.Encode(hashedPasswordBytes);
+            options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
 
 
             using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
             using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);

+ 2 - 2
Emby.Server.Implementations/LiveTv/LiveTvManager.cs

@@ -1429,7 +1429,7 @@ namespace Emby.Server.Implementations.LiveTv
             return result;
             return result;
         }
         }
 
 
-        public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> tuples, ItemFields[] fields, User user = null)
+        public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> tuples, IReadOnlyList<ItemFields> fields, User user = null)
         {
         {
             var programTuples = new List<Tuple<BaseItemDto, string, string>>();
             var programTuples = new List<Tuple<BaseItemDto, string, string>>();
             var hasChannelImage = fields.Contains(ItemFields.ChannelImage);
             var hasChannelImage = fields.Contains(ItemFields.ChannelImage);
@@ -2208,7 +2208,7 @@ namespace Emby.Server.Implementations.LiveTv
         /// <returns>Task.</returns>
         /// <returns>Task.</returns>
         public Task ResetTuner(string id, CancellationToken cancellationToken)
         public Task ResetTuner(string id, CancellationToken cancellationToken)
         {
         {
-            var parts = id.Split(new[] { '_' }, 2);
+            var parts = id.Split('_', 2);
 
 
             var service = _services.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), parts[0], StringComparison.OrdinalIgnoreCase));
             var service = _services.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), parts[0], StringComparison.OrdinalIgnoreCase));
 
 

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

@@ -182,7 +182,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
 
 
             if (string.IsNullOrEmpty(currentFile))
             if (string.IsNullOrEmpty(currentFile))
             {
             {
-                return (files.Last(), true);
+                return (files[^1], true);
             }
             }
 
 
             var nextIndex = files.FindIndex(i => string.Equals(i, currentFile, StringComparison.OrdinalIgnoreCase)) + 1;
             var nextIndex = files.FindIndex(i => string.Equals(i, currentFile, StringComparison.OrdinalIgnoreCase)) + 1;

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

@@ -163,7 +163,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
 
 
         private string GetChannelNumber(string extInf, Dictionary<string, string> attributes, string mediaUrl)
         private string GetChannelNumber(string extInf, Dictionary<string, string> attributes, string mediaUrl)
         {
         {
-            var nameParts = extInf.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
+            var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries);
             var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty;
             var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty;
 
 
             string numberString = null;
             string numberString = null;
@@ -273,8 +273,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
 
 
         private static string GetChannelName(string extInf, Dictionary<string, string> attributes)
         private static string GetChannelName(string extInf, Dictionary<string, string> attributes)
         {
         {
-            var nameParts = extInf.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
-            var nameInExtInf = nameParts.Length > 1 ? nameParts.Last().Trim() : null;
+            var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries);
+            var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].Trim() : null;
 
 
             // Check for channel number with the format from SatIp
             // Check for channel number with the format from SatIp
             // #EXTINF:0,84. VOX Schweiz
             // #EXTINF:0,84. VOX Schweiz

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

@@ -113,5 +113,7 @@
     "TasksChannelsCategory": "Internet Channels",
     "TasksChannelsCategory": "Internet Channels",
     "TasksApplicationCategory": "Application",
     "TasksApplicationCategory": "Application",
     "TasksLibraryCategory": "Library",
     "TasksLibraryCategory": "Library",
-    "TasksMaintenanceCategory": "Maintenance"
+    "TasksMaintenanceCategory": "Maintenance",
+    "TaskCleanActivityLogDescription": "Deletes activity log entries older than the configured age.",
+    "TaskCleanActivityLog": "Clean Activity Log"
 }
 }

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

@@ -77,7 +77,7 @@
     "SubtitleDownloadFailureFromForItem": "Fallo de descarga de subtítulos desde {0} para {1}",
     "SubtitleDownloadFailureFromForItem": "Fallo de descarga de subtítulos desde {0} para {1}",
     "Sync": "Sincronizar",
     "Sync": "Sincronizar",
     "System": "Sistema",
     "System": "Sistema",
-    "TvShows": "Programas de televisión",
+    "TvShows": "Series",
     "User": "Usuario",
     "User": "Usuario",
     "UserCreatedWithName": "El usuario {0} ha sido creado",
     "UserCreatedWithName": "El usuario {0} ha sido creado",
     "UserDeletedWithName": "El usuario {0} ha sido borrado",
     "UserDeletedWithName": "El usuario {0} ha sido borrado",
@@ -113,5 +113,7 @@
     "TaskRefreshChannels": "Actualizar canales",
     "TaskRefreshChannels": "Actualizar canales",
     "TaskRefreshChannelsDescription": "Actualiza la información de los canales de internet.",
     "TaskRefreshChannelsDescription": "Actualiza la información de los canales de internet.",
     "TaskDownloadMissingSubtitles": "Descargar los subtítulos que faltan",
     "TaskDownloadMissingSubtitles": "Descargar los subtítulos que faltan",
-    "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten en el contenido de tus bibliotecas, basándose en la configuración de los metadatos."
+    "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten en el contenido de tus bibliotecas, basándose en la configuración de los metadatos.",
+    "TaskCleanActivityLogDescription": "Elimina todos los registros de actividad anteriores a la fecha configurada.",
+    "TaskCleanActivityLog": "Limpiar registro de actividad"
 }
 }

+ 23 - 6
Emby.Server.Implementations/Localization/Core/fil.json

@@ -1,7 +1,7 @@
 {
 {
     "VersionNumber": "Bersyon {0}",
     "VersionNumber": "Bersyon {0}",
     "ValueSpecialEpisodeName": "Espesyal - {0}",
     "ValueSpecialEpisodeName": "Espesyal - {0}",
-    "ValueHasBeenAddedToLibrary": "Naidagdag na ang {0} sa iyong media library",
+    "ValueHasBeenAddedToLibrary": "Naidagdag na ang {0} sa iyong librerya ng medya",
     "UserStoppedPlayingItemWithValues": "Natapos ni {0} ang {1} sa {2}",
     "UserStoppedPlayingItemWithValues": "Natapos ni {0} ang {1} sa {2}",
     "UserStartedPlayingItemWithValues": "Si {0} ay nagplaplay ng {1} sa {2}",
     "UserStartedPlayingItemWithValues": "Si {0} ay nagplaplay ng {1} sa {2}",
     "UserPolicyUpdatedWithName": "Ang user policy ay naiupdate para kay {0}",
     "UserPolicyUpdatedWithName": "Ang user policy ay naiupdate para kay {0}",
@@ -61,8 +61,8 @@
     "Latest": "Pinakabago",
     "Latest": "Pinakabago",
     "LabelRunningTimeValue": "Oras: {0}",
     "LabelRunningTimeValue": "Oras: {0}",
     "LabelIpAddressValue": "Ang IP Address ay {0}",
     "LabelIpAddressValue": "Ang IP Address ay {0}",
-    "ItemRemovedWithName": "Naitanggal ang {0} sa library",
-    "ItemAddedWithName": "Naidagdag ang {0} sa library",
+    "ItemRemovedWithName": "Naitanggal ang {0} sa librerya",
+    "ItemAddedWithName": "Naidagdag ang {0} sa librerya",
     "Inherit": "Manahin",
     "Inherit": "Manahin",
     "HeaderRecordingGroups": "Pagtatalang Grupo",
     "HeaderRecordingGroups": "Pagtatalang Grupo",
     "HeaderNextUp": "Susunod",
     "HeaderNextUp": "Susunod",
@@ -90,12 +90,29 @@
     "Application": "Aplikasyon",
     "Application": "Aplikasyon",
     "AppDeviceValues": "Aplikasyon: {0}, Aparato: {1}",
     "AppDeviceValues": "Aplikasyon: {0}, Aparato: {1}",
     "Albums": "Albums",
     "Albums": "Albums",
-    "TaskRefreshLibrary": "Suriin ang nasa librerya",
-    "TaskRefreshChapterImagesDescription": "Gumawa ng larawan para sa mga pelikula na may kabanata",
+    "TaskRefreshLibrary": "Suriin and Librerya ng Medya",
+    "TaskRefreshChapterImagesDescription": "Gumawa ng larawan para sa mga pelikula na may kabanata.",
     "TaskRefreshChapterImages": "Kunin ang mga larawan ng kabanata",
     "TaskRefreshChapterImages": "Kunin ang mga larawan ng kabanata",
     "TaskCleanCacheDescription": "Tanggalin ang mga cache file na hindi na kailangan ng systema.",
     "TaskCleanCacheDescription": "Tanggalin ang mga cache file na hindi na kailangan ng systema.",
     "TasksChannelsCategory": "Palabas sa internet",
     "TasksChannelsCategory": "Palabas sa internet",
     "TasksLibraryCategory": "Librerya",
     "TasksLibraryCategory": "Librerya",
     "TasksMaintenanceCategory": "Pagpapanatili",
     "TasksMaintenanceCategory": "Pagpapanatili",
-    "HomeVideos": "Sariling pelikula"
+    "HomeVideos": "Sariling pelikula",
+    "TaskRefreshPeopleDescription": "Ini-update ang metadata para sa mga aktor at direktor sa iyong librerya ng medya.",
+    "TaskRefreshPeople": "I-refresh ang Tauhan",
+    "TaskDownloadMissingSubtitlesDescription": "Hinahanap sa internet ang mga nawawalang subtiles base sa metadata configuration.",
+    "TaskDownloadMissingSubtitles": "I-download and nawawalang subtitles",
+    "TaskRefreshChannelsDescription": "Ni-rerefresh ang impormasyon sa internet channels.",
+    "TaskRefreshChannels": "I-refresh ang Channels",
+    "TaskCleanTranscodeDescription": "Binubura ang transcode files na mas matanda ng isang araw.",
+    "TaskUpdatePluginsDescription": "Nag download at install ng updates sa plugins na naka configure para sa automatikong pag update.",
+    "TaskUpdatePlugins": "I-update ang Plugins",
+    "TaskCleanLogsDescription": "Binubura and files ng talaan na mas mantanda ng {0} araw.",
+    "TaskCleanTranscode": "Linisin and Direktoryo ng Transcode",
+    "TaskCleanLogs": "Linisin and Direktoryo ng Talaan",
+    "TaskRefreshLibraryDescription": "Sinusuri ang iyong librerya ng medya para sa bagong files at irefresh ang metadata.",
+    "TaskCleanCache": "Linisin and Direktoryo ng Cache",
+    "TasksApplicationCategory": "Application",
+    "TaskCleanActivityLog": "Linisin ang Tala ng Aktibidad",
+    "TaskCleanActivityLogDescription": "Tanggalin ang mga tala ng aktibidad na mas matanda sa naka configure na edad."
 }
 }

+ 61 - 59
Emby.Server.Implementations/Localization/Core/hr.json

@@ -5,13 +5,13 @@
     "Artists": "Izvođači",
     "Artists": "Izvođači",
     "AuthenticationSucceededWithUserName": "{0} uspješno ovjerena",
     "AuthenticationSucceededWithUserName": "{0} uspješno ovjerena",
     "Books": "Knjige",
     "Books": "Knjige",
-    "CameraImageUploadedFrom": "Nova fotografija sa kamere je uploadana iz {0}",
+    "CameraImageUploadedFrom": "Nova fotografija sa kamere je učitana iz {0}",
     "Channels": "Kanali",
     "Channels": "Kanali",
     "ChapterNameValue": "Poglavlje {0}",
     "ChapterNameValue": "Poglavlje {0}",
     "Collections": "Kolekcije",
     "Collections": "Kolekcije",
-    "DeviceOfflineWithName": "{0} se odspojilo",
-    "DeviceOnlineWithName": "{0} je spojeno",
-    "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave za {0}",
+    "DeviceOfflineWithName": "{0} je prekinuo vezu",
+    "DeviceOnlineWithName": "{0} je povezan",
+    "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave od {0}",
     "Favorites": "Favoriti",
     "Favorites": "Favoriti",
     "Folders": "Mape",
     "Folders": "Mape",
     "Genres": "Žanrovi",
     "Genres": "Žanrovi",
@@ -23,95 +23,97 @@
     "HeaderFavoriteShows": "Omiljene serije",
     "HeaderFavoriteShows": "Omiljene serije",
     "HeaderFavoriteSongs": "Omiljene pjesme",
     "HeaderFavoriteSongs": "Omiljene pjesme",
     "HeaderLiveTV": "TV uživo",
     "HeaderLiveTV": "TV uživo",
-    "HeaderNextUp": "Sljedeće je",
+    "HeaderNextUp": "Slijedi",
     "HeaderRecordingGroups": "Grupa snimka",
     "HeaderRecordingGroups": "Grupa snimka",
-    "HomeVideos": "Kućni videi",
+    "HomeVideos": "Kućni video",
     "Inherit": "Naslijedi",
     "Inherit": "Naslijedi",
     "ItemAddedWithName": "{0} je dodano u biblioteku",
     "ItemAddedWithName": "{0} je dodano u biblioteku",
-    "ItemRemovedWithName": "{0} je uklonjen iz biblioteke",
+    "ItemRemovedWithName": "{0} je uklonjeno iz biblioteke",
     "LabelIpAddressValue": "IP adresa: {0}",
     "LabelIpAddressValue": "IP adresa: {0}",
     "LabelRunningTimeValue": "Vrijeme rada: {0}",
     "LabelRunningTimeValue": "Vrijeme rada: {0}",
     "Latest": "Najnovije",
     "Latest": "Najnovije",
-    "MessageApplicationUpdated": "Jellyfin Server je ažuriran",
-    "MessageApplicationUpdatedTo": "Jellyfin Server je ažuriran na {0}",
-    "MessageNamedServerConfigurationUpdatedWithValue": "Odjeljak postavka servera {0} je ažuriran",
-    "MessageServerConfigurationUpdated": "Postavke servera su ažurirane",
+    "MessageApplicationUpdated": "Jellyfin server je ažuriran",
+    "MessageApplicationUpdatedTo": "Jellyfin server je ažuriran na {0}",
+    "MessageNamedServerConfigurationUpdatedWithValue": "Dio konfiguracije servera {0} je ažuriran",
+    "MessageServerConfigurationUpdated": "Konfiguracija servera je ažurirana",
     "MixedContent": "Miješani sadržaj",
     "MixedContent": "Miješani sadržaj",
     "Movies": "Filmovi",
     "Movies": "Filmovi",
     "Music": "Glazba",
     "Music": "Glazba",
     "MusicVideos": "Glazbeni spotovi",
     "MusicVideos": "Glazbeni spotovi",
     "NameInstallFailed": "{0} neuspješnih instalacija",
     "NameInstallFailed": "{0} neuspješnih instalacija",
     "NameSeasonNumber": "Sezona {0}",
     "NameSeasonNumber": "Sezona {0}",
-    "NameSeasonUnknown": "Nepoznata sezona",
+    "NameSeasonUnknown": "Sezona nepoznata",
     "NewVersionIsAvailable": "Nova verzija Jellyfin servera je dostupna za preuzimanje.",
     "NewVersionIsAvailable": "Nova verzija Jellyfin servera je dostupna za preuzimanje.",
-    "NotificationOptionApplicationUpdateAvailable": "Dostupno ažuriranje aplikacije",
-    "NotificationOptionApplicationUpdateInstalled": "Instalirano ažuriranje aplikacije",
-    "NotificationOptionAudioPlayback": "Reprodukcija glazbe započeta",
-    "NotificationOptionAudioPlaybackStopped": "Reprodukcija audiozapisa je zaustavljena",
-    "NotificationOptionCameraImageUploaded": "Slike kamere preuzete",
-    "NotificationOptionInstallationFailed": "Instalacija neuspješna",
-    "NotificationOptionNewLibraryContent": "Novi sadržaj je dodan",
-    "NotificationOptionPluginError": "Dodatak otkazao",
+    "NotificationOptionApplicationUpdateAvailable": "Dostupno je ažuriranje aplikacije",
+    "NotificationOptionApplicationUpdateInstalled": "Instalirano je ažuriranje aplikacije",
+    "NotificationOptionAudioPlayback": "Reprodukcija glazbe započela",
+    "NotificationOptionAudioPlaybackStopped": "Reprodukcija glazbe zaustavljena",
+    "NotificationOptionCameraImageUploaded": "Slika s kamere učitana",
+    "NotificationOptionInstallationFailed": "Instalacija nije uspjela",
+    "NotificationOptionNewLibraryContent": "Novi sadržaj dodan",
+    "NotificationOptionPluginError": "Dodatak zakazao",
     "NotificationOptionPluginInstalled": "Dodatak instaliran",
     "NotificationOptionPluginInstalled": "Dodatak instaliran",
-    "NotificationOptionPluginUninstalled": "Dodatak uklonjen",
-    "NotificationOptionPluginUpdateInstalled": "Instalirano ažuriranje za dodatak",
-    "NotificationOptionServerRestartRequired": "Potrebno ponovo pokretanje servera",
-    "NotificationOptionTaskFailed": "Zakazan zadatak nije izvršen",
+    "NotificationOptionPluginUninstalled": "Dodatak deinstaliran",
+    "NotificationOptionPluginUpdateInstalled": "Instalirano ažuriranje dodatka",
+    "NotificationOptionServerRestartRequired": "Ponovno pokrenite server",
+    "NotificationOptionTaskFailed": "Greška zakazanog zadatka",
     "NotificationOptionUserLockedOut": "Korisnik zaključan",
     "NotificationOptionUserLockedOut": "Korisnik zaključan",
-    "NotificationOptionVideoPlayback": "Reprodukcija videa započeta",
-    "NotificationOptionVideoPlaybackStopped": "Reprodukcija videozapisa je zaustavljena",
-    "Photos": "Slike",
-    "Playlists": "Popis za reprodukciju",
+    "NotificationOptionVideoPlayback": "Reprodukcija videa započela",
+    "NotificationOptionVideoPlaybackStopped": "Reprodukcija videa zaustavljena",
+    "Photos": "Fotografije",
+    "Playlists": "Popisi za reprodukciju",
     "Plugin": "Dodatak",
     "Plugin": "Dodatak",
     "PluginInstalledWithName": "{0} je instalirano",
     "PluginInstalledWithName": "{0} je instalirano",
     "PluginUninstalledWithName": "{0} je deinstalirano",
     "PluginUninstalledWithName": "{0} je deinstalirano",
     "PluginUpdatedWithName": "{0} je ažurirano",
     "PluginUpdatedWithName": "{0} je ažurirano",
-    "ProviderValue": "Pružitelj: {0}",
+    "ProviderValue": "Pružatelj: {0}",
     "ScheduledTaskFailedWithName": "{0} neuspjelo",
     "ScheduledTaskFailedWithName": "{0} neuspjelo",
     "ScheduledTaskStartedWithName": "{0} pokrenuto",
     "ScheduledTaskStartedWithName": "{0} pokrenuto",
-    "ServerNameNeedsToBeRestarted": "{0} treba biti ponovno pokrenuto",
+    "ServerNameNeedsToBeRestarted": "{0} treba ponovno pokrenuti",
     "Shows": "Serije",
     "Shows": "Serije",
     "Songs": "Pjesme",
     "Songs": "Pjesme",
-    "StartupEmbyServerIsLoading": "Jellyfin Server se učitava. Pokušajte ponovo kasnije.",
+    "StartupEmbyServerIsLoading": "Jellyfin server se učitava. Pokušajte ponovo uskoro.",
     "SubtitleDownloadFailureForItem": "Titlovi prijevoda nisu preuzeti za {0}",
     "SubtitleDownloadFailureForItem": "Titlovi prijevoda nisu preuzeti za {0}",
-    "SubtitleDownloadFailureFromForItem": "Prijevodi nisu uspješno preuzeti {0} od {1}",
-    "Sync": "Sink.",
-    "System": "Sistem",
+    "SubtitleDownloadFailureFromForItem": "Prijevod nije uspješno preuzet od {0} za {1}",
+    "Sync": "Sinkronizacija",
+    "System": "Sustav",
     "TvShows": "Serije",
     "TvShows": "Serije",
     "User": "Korisnik",
     "User": "Korisnik",
-    "UserCreatedWithName": "Korisnik {0} je stvoren",
+    "UserCreatedWithName": "Korisnik {0} je kreiran",
     "UserDeletedWithName": "Korisnik {0} je obrisan",
     "UserDeletedWithName": "Korisnik {0} je obrisan",
-    "UserDownloadingItemWithValues": "{0} se preuzima {1}",
+    "UserDownloadingItemWithValues": "{0} preuzima {1}",
     "UserLockedOutWithName": "Korisnik {0} je zaključan",
     "UserLockedOutWithName": "Korisnik {0} je zaključan",
-    "UserOfflineFromDevice": "{0} se odspojilo od {1}",
-    "UserOnlineFromDevice": "{0} je online od {1}",
+    "UserOfflineFromDevice": "{0} prekinuo vezu od {1}",
+    "UserOnlineFromDevice": "{0} povezan od {1}",
     "UserPasswordChangedWithName": "Lozinka je promijenjena za korisnika {0}",
     "UserPasswordChangedWithName": "Lozinka je promijenjena za korisnika {0}",
-    "UserPolicyUpdatedWithName": "Pravila za korisnika su ažurirana za {0}",
-    "UserStartedPlayingItemWithValues": "{0} je pokrenuo {1}",
-    "UserStoppedPlayingItemWithValues": "{0} je zaustavio {1}",
+    "UserPolicyUpdatedWithName": "Pravila za korisnika ažurirana su za {0}",
+    "UserStartedPlayingItemWithValues": "{0} je pokrenuo reprodukciju {1} na {2}",
+    "UserStoppedPlayingItemWithValues": "{0} je zavio reprodukciju {1} na {2}",
     "ValueHasBeenAddedToLibrary": "{0} je dodano u medijsku biblioteku",
     "ValueHasBeenAddedToLibrary": "{0} je dodano u medijsku biblioteku",
-    "ValueSpecialEpisodeName": "Specijal - {0}",
+    "ValueSpecialEpisodeName": "Posebno - {0}",
     "VersionNumber": "Verzija {0}",
     "VersionNumber": "Verzija {0}",
-    "TaskRefreshLibraryDescription": "Skenira vašu medijsku knjižnicu sa novim datotekama i osvježuje metapodatke.",
-    "TaskRefreshLibrary": "Skeniraj medijsku knjižnicu",
-    "TaskRefreshChapterImagesDescription": "Stvara sličice za videozapise koji imaju poglavlja.",
-    "TaskRefreshChapterImages": "Raspakiraj slike poglavlja",
-    "TaskCleanCacheDescription": "Briše priručne datoteke nepotrebne za sistem.",
-    "TaskCleanCache": "Očisti priručnu memoriju",
+    "TaskRefreshLibraryDescription": "Skenira medijsku biblioteku radi novih datoteka i osvježava metapodatke.",
+    "TaskRefreshLibrary": "Skeniraj medijsku biblioteku",
+    "TaskRefreshChapterImagesDescription": "Kreira sličice za videozapise koji imaju poglavlja.",
+    "TaskRefreshChapterImages": "Izdvoji slike poglavlja",
+    "TaskCleanCacheDescription": "Briše nepotrebne datoteke iz predmemorije.",
+    "TaskCleanCache": "Očisti mapu predmemorije",
     "TasksApplicationCategory": "Aplikacija",
     "TasksApplicationCategory": "Aplikacija",
     "TasksMaintenanceCategory": "Održavanje",
     "TasksMaintenanceCategory": "Održavanje",
-    "TaskDownloadMissingSubtitlesDescription": "Pretraživanje interneta za prijevodima koji nedostaju bazirano na konfiguraciji meta podataka.",
-    "TaskDownloadMissingSubtitles": "Preuzimanje prijevoda koji nedostaju",
-    "TaskRefreshChannelsDescription": "Osvježava informacije o internet kanalima.",
+    "TaskDownloadMissingSubtitlesDescription": "Pretraži Internet za prijevodima koji nedostaju prema konfiguraciji metapodataka.",
+    "TaskDownloadMissingSubtitles": "Preuzmi prijevod koji nedostaje",
+    "TaskRefreshChannelsDescription": "Osvježava informacije Internet kanala.",
     "TaskRefreshChannels": "Osvježi kanale",
     "TaskRefreshChannels": "Osvježi kanale",
-    "TaskCleanTranscodeDescription": "Briše transkodirane fajlove starije od jednog dana.",
-    "TaskCleanTranscode": "Očisti direktorij za transkodiranje",
-    "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja za dodatke koji su podešeni da se ažuriraju automatski.",
+    "TaskCleanTranscodeDescription": "Briše transkodirane datoteke starije od jednog dana.",
+    "TaskCleanTranscode": "Očisti mapu transkodiranja",
+    "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja za dodatke koji su konfigurirani da se ažuriraju automatski.",
     "TaskUpdatePlugins": "Ažuriraj dodatke",
     "TaskUpdatePlugins": "Ažuriraj dodatke",
-    "TaskRefreshPeopleDescription": "Ažurira meta podatke za glumce i redatelje u vašoj medijskoj biblioteci.",
-    "TaskRefreshPeople": "Osvježi ljude",
-    "TaskCleanLogsDescription": "Briši logove koji su stariji od {0} dana.",
-    "TaskCleanLogs": "Očisti direktorij sa logovima",
+    "TaskRefreshPeopleDescription": "Ažurira metapodatke za glumce i redatelje u medijskoj biblioteci.",
+    "TaskRefreshPeople": "Osvježi osobe",
+    "TaskCleanLogsDescription": "Briše zapise dnevnika koji su stariji od {0} dana.",
+    "TaskCleanLogs": "Očisti mapu dnevnika zapisa",
     "TasksChannelsCategory": "Internet kanali",
     "TasksChannelsCategory": "Internet kanali",
-    "TasksLibraryCategory": "Biblioteka"
+    "TasksLibraryCategory": "Biblioteka",
+    "TaskCleanActivityLogDescription": "Briše zapise dnevnika aktivnosti starije od navedenog vremena.",
+    "TaskCleanActivityLog": "Očisti dnevnik aktivnosti"
 }
 }

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

@@ -113,5 +113,7 @@
     "TaskDownloadMissingSubtitles": "Hiányzó feliratok letöltése",
     "TaskDownloadMissingSubtitles": "Hiányzó feliratok letöltése",
     "TaskRefreshChannelsDescription": "Frissíti az internetes csatornák adatait.",
     "TaskRefreshChannelsDescription": "Frissíti az internetes csatornák adatait.",
     "TaskRefreshChannels": "Csatornák frissítése",
     "TaskRefreshChannels": "Csatornák frissítése",
-    "TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat."
+    "TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat.",
+    "TaskCleanActivityLogDescription": "A beállítottnál korábbi bejegyzések törlése a tevékenységnaplóból.",
+    "TaskCleanActivityLog": "Tevékenységnapló törlése"
 }
 }

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

@@ -113,5 +113,7 @@
     "TasksChannelsCategory": "Internet Kanalen",
     "TasksChannelsCategory": "Internet Kanalen",
     "TasksApplicationCategory": "Applicatie",
     "TasksApplicationCategory": "Applicatie",
     "TasksLibraryCategory": "Bibliotheek",
     "TasksLibraryCategory": "Bibliotheek",
-    "TasksMaintenanceCategory": "Onderhoud"
+    "TasksMaintenanceCategory": "Onderhoud",
+    "TaskCleanActivityLogDescription": "Verwijder activiteiten logs ouder dan de ingestelde tijd.",
+    "TaskCleanActivityLog": "Leeg activiteiten logboek"
 }
 }

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

@@ -112,5 +112,7 @@
     "TasksChannelsCategory": "Canale de pe Internet",
     "TasksChannelsCategory": "Canale de pe Internet",
     "TasksApplicationCategory": "Aplicație",
     "TasksApplicationCategory": "Aplicație",
     "TasksLibraryCategory": "Librărie",
     "TasksLibraryCategory": "Librărie",
-    "TasksMaintenanceCategory": "Mentenanță"
+    "TasksMaintenanceCategory": "Mentenanță",
+    "TaskCleanActivityLogDescription": "Șterge intrările din jurnalul de activitate mai vechi de data configurată.",
+    "TaskCleanActivityLog": "Curăță Jurnalul de Activitate"
 }
 }

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

@@ -112,5 +112,7 @@
     "TasksChannelsCategory": "Интернет канали",
     "TasksChannelsCategory": "Интернет канали",
     "TasksApplicationCategory": "Апликација",
     "TasksApplicationCategory": "Апликација",
     "TasksLibraryCategory": "Библиотека",
     "TasksLibraryCategory": "Библиотека",
-    "TasksMaintenanceCategory": "Одржавање"
+    "TasksMaintenanceCategory": "Одржавање",
+    "TaskCleanActivityLogDescription": "Брише историју активности старију од конфигурисаног броја година.",
+    "TaskCleanActivityLog": "Очисти историју активности"
 }
 }

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

@@ -112,5 +112,7 @@
     "UserOnlineFromDevice": "{1} இருந்து {0} ஆன்லைன்",
     "UserOnlineFromDevice": "{1} இருந்து {0} ஆன்லைன்",
     "HomeVideos": "முகப்பு வீடியோக்கள்",
     "HomeVideos": "முகப்பு வீடியோக்கள்",
     "UserStoppedPlayingItemWithValues": "{0} {2} இல் {1} முடித்துவிட்டது",
     "UserStoppedPlayingItemWithValues": "{0} {2} இல் {1} முடித்துவிட்டது",
-    "UserStartedPlayingItemWithValues": "{0} {2}இல் {1} ஐ இயக்குகிறது"
+    "UserStartedPlayingItemWithValues": "{0} {2}இல் {1} ஐ இயக்குகிறது",
+    "TaskCleanActivityLogDescription": "உள்ளமைக்கப்பட்ட வயதை விட பழைய செயல்பாட்டு பதிவு உள்ளீடுகளை நீக்குகிறது.",
+    "TaskCleanActivityLog": "செயல்பாட்டு பதிவை அழி"
 }
 }

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

@@ -114,5 +114,6 @@
     "TaskRefreshChapterImagesDescription": "Sahnelere ayrılmış videolar için küçük resimler oluştur.",
     "TaskRefreshChapterImagesDescription": "Sahnelere ayrılmış videolar için küçük resimler oluştur.",
     "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar",
     "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar",
     "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.",
     "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.",
-    "TaskCleanActivityLog": "İşlem Günlüğünü Temizle"
+    "TaskCleanActivityLog": "İşlem Günlüğünü Temizle",
+    "TaskCleanActivityLogDescription": "Belirtilen sureden daha eski etkinlik log kayıtları silindi."
 }
 }

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

@@ -112,5 +112,7 @@
     "Books": "Sách",
     "Books": "Sách",
     "AuthenticationSucceededWithUserName": "{0} xác thực thành công",
     "AuthenticationSucceededWithUserName": "{0} xác thực thành công",
     "Application": "Ứng Dụng",
     "Application": "Ứng Dụng",
-    "AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}"
+    "AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}",
+    "TaskCleanActivityLogDescription": "Xóa các mục nhật ký hoạt động cũ hơn độ tuổi đã cài đặt.",
+    "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động"
 }
 }

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

@@ -113,5 +113,7 @@
     "TaskCleanCacheDescription": "删除系统不再需要的缓存文件。",
     "TaskCleanCacheDescription": "删除系统不再需要的缓存文件。",
     "TaskCleanCache": "清理缓存目录",
     "TaskCleanCache": "清理缓存目录",
     "TasksApplicationCategory": "应用程序",
     "TasksApplicationCategory": "应用程序",
-    "TasksMaintenanceCategory": "维护"
+    "TasksMaintenanceCategory": "维护",
+    "TaskCleanActivityLog": "清理程序日志",
+    "TaskCleanActivityLogDescription": "删除早于设置时间的活动日志条目。"
 }
 }

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

@@ -112,5 +112,7 @@
     "TaskRefreshChapterImagesDescription": "為有章節的影片建立縮圖。",
     "TaskRefreshChapterImagesDescription": "為有章節的影片建立縮圖。",
     "TasksChannelsCategory": "網路頻道",
     "TasksChannelsCategory": "網路頻道",
     "TasksApplicationCategory": "應用程式",
     "TasksApplicationCategory": "應用程式",
-    "TasksMaintenanceCategory": "維修"
+    "TasksMaintenanceCategory": "維護",
+    "TaskCleanActivityLogDescription": "刪除超過所設時間的活動紀錄。",
+    "TaskCleanActivityLog": "清除活動紀錄"
 }
 }

+ 1 - 1
Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs

@@ -653,7 +653,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
                     try
                     try
                     {
                     {
                         _logger.LogInformation(Name + ": Waiting on Task");
                         _logger.LogInformation(Name + ": Waiting on Task");
-                        var exited = Task.WaitAll(new[] { task }, 2000);
+                        var exited = task.Wait(2000);
 
 
                         if (exited)
                         if (exited)
                         {
                         {

+ 1 - 1
Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs

@@ -106,7 +106,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
                 try
                 try
                 {
                 {
                     previouslyFailedImages = File.ReadAllText(failHistoryPath)
                     previouslyFailedImages = File.ReadAllText(failHistoryPath)
-                        .Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
+                        .Split('|', StringSplitOptions.RemoveEmptyEntries)
                         .ToList();
                         .ToList();
                 }
                 }
                 catch (IOException)
                 catch (IOException)

+ 3 - 2
Emby.Server.Implementations/Session/WebSocketController.cs

@@ -8,6 +8,7 @@ using System.Linq;
 using System.Net.WebSockets;
 using System.Net.WebSockets;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Net;
@@ -55,9 +56,9 @@ namespace Emby.Server.Implementations.Session
             connection.Closed += OnConnectionClosed;
             connection.Closed += OnConnectionClosed;
         }
         }
 
 
-        private void OnConnectionClosed(object sender, EventArgs e)
+        private void OnConnectionClosed(object? sender, EventArgs e)
         {
         {
-            var connection = (IWebSocketConnection)sender;
+            var connection = sender as IWebSocketConnection ?? throw new ArgumentException($"{nameof(sender)} is not of type {nameof(IWebSocketConnection)}", nameof(sender));
             _logger.LogDebug("Removing websocket from session {Session}", _session.Id);
             _logger.LogDebug("Removing websocket from session {Session}", _session.Id);
             _sockets.Remove(connection);
             _sockets.Remove(connection);
             connection.Closed -= OnConnectionClosed;
             connection.Closed -= OnConnectionClosed;

+ 5 - 5
Emby.Server.Implementations/Updates/InstallationManager.cs

@@ -16,7 +16,7 @@ using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Common.Updates;
 using MediaBrowser.Common.Updates;
-using MediaBrowser.Common.System;
+using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Events;
 using MediaBrowser.Controller.Events;
 using MediaBrowser.Controller.Events.Updates;
 using MediaBrowser.Controller.Events.Updates;
@@ -25,7 +25,6 @@ using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Updates;
 using MediaBrowser.Model.Updates;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
-using MediaBrowser.Model.System;
 
 
 namespace Emby.Server.Implementations.Updates
 namespace Emby.Server.Implementations.Updates
 {
 {
@@ -49,7 +48,7 @@ namespace Emby.Server.Implementations.Updates
         /// Gets the application host.
         /// Gets the application host.
         /// </summary>
         /// </summary>
         /// <value>The application host.</value>
         /// <value>The application host.</value>
-        private readonly IApplicationHost _applicationHost;
+        private readonly IServerApplicationHost _applicationHost;
 
 
         private readonly IZipClient _zipClient;
         private readonly IZipClient _zipClient;
 
 
@@ -67,7 +66,7 @@ namespace Emby.Server.Implementations.Updates
 
 
         public InstallationManager(
         public InstallationManager(
             ILogger<InstallationManager> logger,
             ILogger<InstallationManager> logger,
-            IApplicationHost appHost,
+            IServerApplicationHost appHost,
             IApplicationPaths appPaths,
             IApplicationPaths appPaths,
             IEventManager eventManager,
             IEventManager eventManager,
             IHttpClientFactory httpClientFactory,
             IHttpClientFactory httpClientFactory,
@@ -217,7 +216,8 @@ namespace Emby.Server.Implementations.Updates
 
 
         private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog)
         private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog)
         {
         {
-            foreach (var plugin in _applicationHost.Plugins)
+            var plugins = _applicationHost.GetLocalPlugins(_appPaths.PluginsPath);
+            foreach (var plugin in plugins)
             {
             {
                 var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin.Version);
                 var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin.Version);
                 var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version);
                 var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version);

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

@@ -50,6 +50,13 @@ namespace Jellyfin.Api.Auth
             bool localAccessOnly = false,
             bool localAccessOnly = false,
             bool requiredDownloadPermission = false)
             bool requiredDownloadPermission = false)
         {
         {
+            // ApiKey is currently global admin, always allow.
+            var isApiKey = ClaimHelpers.GetIsApiKey(claimsPrincipal);
+            if (isApiKey)
+            {
+                return true;
+            }
+
             // Ensure claim has userId.
             // Ensure claim has userId.
             var userId = ClaimHelpers.GetUserId(claimsPrincipal);
             var userId = ClaimHelpers.GetUserId(claimsPrincipal);
             if (!userId.HasValue)
             if (!userId.HasValue)

+ 7 - 8
Jellyfin.Api/Auth/CustomAuthenticationHandler.cs

@@ -1,10 +1,10 @@
 using System.Globalization;
 using System.Globalization;
-using System.Security.Authentication;
 using System.Security.Claims;
 using System.Security.Claims;
 using System.Text.Encodings.Web;
 using System.Text.Encodings.Web;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
@@ -43,24 +43,23 @@ namespace Jellyfin.Api.Auth
             try
             try
             {
             {
                 var authorizationInfo = _authService.Authenticate(Request);
                 var authorizationInfo = _authService.Authenticate(Request);
-                if (authorizationInfo == null)
+                var role = UserRoles.User;
+                if (authorizationInfo.IsApiKey || authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
                 {
                 {
-                    return Task.FromResult(AuthenticateResult.NoResult());
-                    // TODO return when legacy API is removed.
-                    // Don't spam the log with "Invalid User"
-                    // return Task.FromResult(AuthenticateResult.Fail("Invalid user"));
+                    role = UserRoles.Administrator;
                 }
                 }
 
 
                 var claims = new[]
                 var claims = new[]
                 {
                 {
-                    new Claim(ClaimTypes.Name, authorizationInfo.User.Username),
-                    new Claim(ClaimTypes.Role, authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User),
+                    new Claim(ClaimTypes.Name, authorizationInfo.User?.Username ?? string.Empty),
+                    new Claim(ClaimTypes.Role, role),
                     new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
                     new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
                     new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId),
                     new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId),
                     new Claim(InternalClaimTypes.Device, authorizationInfo.Device),
                     new Claim(InternalClaimTypes.Device, authorizationInfo.Device),
                     new Claim(InternalClaimTypes.Client, authorizationInfo.Client),
                     new Claim(InternalClaimTypes.Client, authorizationInfo.Client),
                     new Claim(InternalClaimTypes.Version, authorizationInfo.Version),
                     new Claim(InternalClaimTypes.Version, authorizationInfo.Version),
                     new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
                     new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
+                    new Claim(InternalClaimTypes.IsApiKey, authorizationInfo.IsApiKey.ToString(CultureInfo.InvariantCulture))
                 };
                 };
 
 
                 var identity = new ClaimsIdentity(claims, Scheme.Name);
                 var identity = new ClaimsIdentity(claims, Scheme.Name);

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

@@ -29,13 +29,15 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement)
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement)
         {
         {
             var validated = ValidateClaims(context.User);
             var validated = ValidateClaims(context.User);
-            if (!validated)
+            if (validated)
+            {
+                context.Succeed(requirement);
+            }
+            else
             {
             {
                 context.Fail();
                 context.Fail();
-                return Task.CompletedTask;
             }
             }
 
 
-            context.Succeed(requirement);
             return Task.CompletedTask;
             return Task.CompletedTask;
         }
         }
     }
     }

+ 5 - 3
Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs

@@ -29,13 +29,15 @@ namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement)
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement)
         {
         {
             var validated = ValidateClaims(context.User, ignoreSchedule: true);
             var validated = ValidateClaims(context.User, ignoreSchedule: true);
-            if (!validated)
+            if (validated)
+            {
+                context.Succeed(requirement);
+            }
+            else
             {
             {
                 context.Fail();
                 context.Fail();
-                return Task.CompletedTask;
             }
             }
 
 
-            context.Succeed(requirement);
             return Task.CompletedTask;
             return Task.CompletedTask;
         }
         }
     }
     }

+ 3 - 3
Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs

@@ -29,13 +29,13 @@ namespace Jellyfin.Api.Auth.LocalAccessPolicy
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement)
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement)
         {
         {
             var validated = ValidateClaims(context.User, localAccessOnly: true);
             var validated = ValidateClaims(context.User, localAccessOnly: true);
-            if (!validated)
+            if (validated)
             {
             {
-                context.Fail();
+                context.Succeed(requirement);
             }
             }
             else
             else
             {
             {
-                context.Succeed(requirement);
+                context.Fail();
             }
             }
 
 
             return Task.CompletedTask;
             return Task.CompletedTask;

+ 5 - 0
Jellyfin.Api/Constants/InternalClaimTypes.cs

@@ -34,5 +34,10 @@
         /// Token.
         /// Token.
         /// </summary>
         /// </summary>
         public const string Token = "Jellyfin-Token";
         public const string Token = "Jellyfin-Token";
+
+        /// <summary>
+        /// Is Api Key.
+        /// </summary>
+        public const string IsApiKey = "Jellyfin-IsApiKey";
     }
     }
 }
 }

+ 0 - 135
Jellyfin.Api/Controllers/AlbumsController.cs

@@ -1,135 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
-using System.Linq;
-using Jellyfin.Api.Extensions;
-using Jellyfin.Api.Helpers;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Audio;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Querying;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-
-namespace Jellyfin.Api.Controllers
-{
-    /// <summary>
-    /// The albums controller.
-    /// </summary>
-    [Route("")]
-    public class AlbumsController : BaseJellyfinApiController
-    {
-        private readonly IUserManager _userManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="AlbumsController"/> 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 AlbumsController(
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IDtoService dtoService)
-        {
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _dtoService = dtoService;
-        }
-
-        /// <summary>
-        /// Finds albums similar to a given album.
-        /// </summary>
-        /// <param name="albumId">The album id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <response code="200">Similar albums returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar albums.</returns>
-        [HttpGet("Albums/{albumId}/Similar")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetSimilarAlbums(
-            [FromRoute, Required] string albumId,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? excludeArtistIds,
-            [FromQuery] int? limit)
-        {
-            var dtoOptions = new DtoOptions().AddClientFields(Request);
-
-            return SimilarItemsHelper.GetSimilarItemsResult(
-                dtoOptions,
-                _userManager,
-                _libraryManager,
-                _dtoService,
-                userId,
-                albumId,
-                excludeArtistIds,
-                limit,
-                new[] { typeof(MusicAlbum) },
-                GetAlbumSimilarityScore);
-        }
-
-        /// <summary>
-        /// Finds artists similar to a given artist.
-        /// </summary>
-        /// <param name="artistId">The artist id.</param>
-        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
-        /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param>
-        /// <param name="limit">Optional. The maximum number of records to return.</param>
-        /// <response code="200">Similar artists returned.</response>
-        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar artists.</returns>
-        [HttpGet("Artists/{artistId}/Similar")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<QueryResult<BaseItemDto>> GetSimilarArtists(
-            [FromRoute, Required] string artistId,
-            [FromQuery] Guid? userId,
-            [FromQuery] string? excludeArtistIds,
-            [FromQuery] int? limit)
-        {
-            var dtoOptions = new DtoOptions().AddClientFields(Request);
-
-            return SimilarItemsHelper.GetSimilarItemsResult(
-                dtoOptions,
-                _userManager,
-                _libraryManager,
-                _dtoService,
-                userId,
-                artistId,
-                excludeArtistIds,
-                limit,
-                new[] { typeof(MusicArtist) },
-                SimilarItemsHelper.GetSimiliarityScore);
-        }
-
-        /// <summary>
-        /// Gets a similairty score of two albums.
-        /// </summary>
-        /// <param name="item1">The first item.</param>
-        /// <param name="item1People">The item1 people.</param>
-        /// <param name="allPeople">All people.</param>
-        /// <param name="item2">The second item.</param>
-        /// <returns>System.Int32.</returns>
-        private int GetAlbumSimilarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> allPeople, BaseItem item2)
-        {
-            var points = SimilarItemsHelper.GetSimiliarityScore(item1, item1People, allPeople, item2);
-
-            var album1 = (MusicAlbum)item1;
-            var album2 = (MusicAlbum)item2;
-
-            var artists1 = album1
-                .GetAllArtists()
-                .DistinctNames()
-                .ToList();
-
-            var artists2 = new HashSet<string>(
-                album2.GetAllArtists().DistinctNames(),
-                StringComparer.OrdinalIgnoreCase);
-
-            return points + artists1.Where(artists2.Contains).Sum(i => 5);
-        }
-    }
-}

+ 8 - 10
Jellyfin.Api/Controllers/ArtistsController.cs

@@ -53,7 +53,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="limit">Optional. The maximum number of records to return.</param>
         /// <param name="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">Optional. Search term.</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="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</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="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="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="filters">Optional. Specify additional filters to apply.</param>
@@ -88,7 +88,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
@@ -101,7 +101,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,
             [FromQuery] string? personTypes,
@@ -114,8 +114,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages = true,
             [FromQuery] bool? enableImages = true,
             [FromQuery] bool enableTotalRecordCount = true)
             [FromQuery] bool enableTotalRecordCount = true)
         {
         {
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 
@@ -262,7 +261,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="limit">Optional. The maximum number of records to return.</param>
         /// <param name="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">Optional. Search term.</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="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</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="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="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="filters">Optional. Specify additional filters to apply.</param>
@@ -297,7 +296,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
@@ -310,7 +309,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,
             [FromQuery] string? personTypes,
@@ -323,8 +322,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages = true,
             [FromQuery] bool? enableImages = true,
             [FromQuery] bool enableTotalRecordCount = true)
             [FromQuery] bool enableTotalRecordCount = true)
         {
         {
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 

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

@@ -108,7 +108,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param>
         /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param>
         /// <param name="filters">Optional. Specify additional filters to apply.</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="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. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <response code="200">Channel items returned.</response>
         /// <response code="200">Channel items returned.</response>
         /// <returns>
         /// <returns>
         /// A <see cref="Task"/> representing the request to get the channel items.
         /// A <see cref="Task"/> representing the request to get the channel items.
@@ -124,7 +124,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? sortOrder,
             [FromQuery] string? sortOrder,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] string? sortBy,
             [FromQuery] string? sortBy,
-            [FromQuery] string? fields)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
         {
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
@@ -137,8 +137,7 @@ namespace Jellyfin.Api.Controllers
                 ChannelIds = new[] { channelId },
                 ChannelIds = new[] { channelId },
                 ParentId = folderId ?? Guid.Empty,
                 ParentId = folderId ?? Guid.Empty,
                 OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
                 OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
-                DtoOptions = new DtoOptions()
-                    .AddItemFields(fields)
+                DtoOptions = new DtoOptions { Fields = fields }
             };
             };
 
 
             foreach (var filter in filters)
             foreach (var filter in filters)
@@ -185,7 +184,7 @@ namespace Jellyfin.Api.Controllers
         /// <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="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="limit">Optional. The maximum number of records to return.</param>
         /// <param name="filters">Optional. Specify additional filters to apply.</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. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</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>
         /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param>
         /// <response code="200">Latest channel items returned.</response>
         /// <response code="200">Latest channel items returned.</response>
         /// <returns>
         /// <returns>
@@ -198,7 +197,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? channelIds)
             [FromQuery] string? channelIds)
         {
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -214,8 +213,7 @@ namespace Jellyfin.Api.Controllers
                     .Where(i => !string.IsNullOrWhiteSpace(i))
                     .Where(i => !string.IsNullOrWhiteSpace(i))
                     .Select(i => new Guid(i))
                     .Select(i => new Guid(i))
                     .ToArray(),
                     .ToArray(),
-                DtoOptions = new DtoOptions()
-                    .AddItemFields(fields)
+                DtoOptions = new DtoOptions { Fields = fields }
             };
             };
 
 
             foreach (var filter in filters)
             foreach (var filter in filters)

+ 3 - 0
Jellyfin.Api/Controllers/DisplayPreferencesController.cs

@@ -81,6 +81,9 @@ namespace Jellyfin.Api.Controllers
             dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
             dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
             dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
             dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
 
 
+            // 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;
         }
         }
 
 

+ 26 - 0
Jellyfin.Api/Controllers/DlnaServerController.cs

@@ -77,6 +77,7 @@ namespace Jellyfin.Api.Controllers
         /// Gets Dlna media receiver registrar xml.
         /// Gets Dlna media receiver registrar xml.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Dlna media receiver registrar xml returned.</response>
         /// <returns>Dlna media receiver registrar xml.</returns>
         /// <returns>Dlna media receiver registrar xml.</returns>
         [HttpGet("{serverId}/MediaReceiverRegistrar")]
         [HttpGet("{serverId}/MediaReceiverRegistrar")]
         [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
         [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
@@ -94,6 +95,7 @@ namespace Jellyfin.Api.Controllers
         /// Gets Dlna media receiver registrar xml.
         /// Gets Dlna media receiver registrar xml.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Dlna media receiver registrar xml returned.</response>
         /// <returns>Dlna media receiver registrar xml.</returns>
         /// <returns>Dlna media receiver registrar xml.</returns>
         [HttpGet("{serverId}/ConnectionManager")]
         [HttpGet("{serverId}/ConnectionManager")]
         [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
         [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
@@ -111,8 +113,12 @@ namespace Jellyfin.Api.Controllers
         /// Process a content directory control request.
         /// Process a content directory control request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Control response.</returns>
         /// <returns>Control response.</returns>
         [HttpPost("{serverId}/ContentDirectory/Control")]
         [HttpPost("{serverId}/ContentDirectory/Control")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId)
         public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId)
         {
         {
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
@@ -122,8 +128,12 @@ namespace Jellyfin.Api.Controllers
         /// Process a connection manager control request.
         /// Process a connection manager control request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Control response.</returns>
         /// <returns>Control response.</returns>
         [HttpPost("{serverId}/ConnectionManager/Control")]
         [HttpPost("{serverId}/ConnectionManager/Control")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId)
         public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId)
         {
         {
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
@@ -133,8 +143,12 @@ namespace Jellyfin.Api.Controllers
         /// Process a media receiver registrar control request.
         /// Process a media receiver registrar control request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Control response.</returns>
         /// <returns>Control response.</returns>
         [HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
         [HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId)
         public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId)
         {
         {
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
             return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
@@ -144,11 +158,15 @@ namespace Jellyfin.Api.Controllers
         /// Processes an event subscription request.
         /// Processes an event subscription request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Event subscription response.</returns>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
         public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
         {
         {
             return ProcessEventRequest(_mediaReceiverRegistrar);
             return ProcessEventRequest(_mediaReceiverRegistrar);
@@ -158,11 +176,15 @@ namespace Jellyfin.Api.Controllers
         /// Processes an event subscription request.
         /// Processes an event subscription request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Event subscription response.</returns>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/ContentDirectory/Events")]
         [HttpSubscribe("{serverId}/ContentDirectory/Events")]
         [HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
         [HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
         public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
         {
         {
             return ProcessEventRequest(_contentDirectory);
             return ProcessEventRequest(_contentDirectory);
@@ -172,11 +194,15 @@ namespace Jellyfin.Api.Controllers
         /// Processes an event subscription request.
         /// Processes an event subscription request.
         /// </summary>
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="serverId">Server UUID.</param>
+        /// <response code="200">Request processed.</response>
         /// <returns>Event subscription response.</returns>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/ConnectionManager/Events")]
         [HttpSubscribe("{serverId}/ConnectionManager/Events")]
         [HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
         [HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Text.Xml)]
+        [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
         public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
         {
         {
             return ProcessEventRequest(_connectionManager);
             return ProcessEventRequest(_connectionManager);

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

@@ -15,6 +15,7 @@ using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Models.PlaybackDtos;
 using Jellyfin.Api.Models.PlaybackDtos;
 using Jellyfin.Api.Models.StreamingDtos;
 using Jellyfin.Api.Models.StreamingDtos;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Dlna;
@@ -1373,8 +1374,9 @@ namespace Jellyfin.Api.Controllers
             var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
             var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
             var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
             var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
 
 
+            var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
             var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
             var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
-            var outputPrefix = Path.Combine(Path.GetDirectoryName(outputPath), outputFileNameWithoutExtension);
+            var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
             var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer);
             var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer);
             var outputTsArg = outputPrefix + "%d" + outputExtension;
             var outputTsArg = outputPrefix + "%d" + outputExtension;
 
 
@@ -1684,8 +1686,7 @@ namespace Jellyfin.Api.Controllers
 
 
         private string GetSegmentPath(StreamState state, string playlist, int index)
         private string GetSegmentPath(StreamState state, string playlist, int index)
         {
         {
-            var folder = Path.GetDirectoryName(playlist);
-
+            var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException($"Provided path ({playlist}) is not valid.", nameof(playlist));
             var filename = Path.GetFileNameWithoutExtension(playlist);
             var filename = Path.GetFileNameWithoutExtension(playlist);
 
 
             return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + GetSegmentFileExtension(state.Request.SegmentContainer));
             return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + GetSegmentFileExtension(state.Request.SegmentContainer));

+ 6 - 0
Jellyfin.Api/Controllers/EnvironmentController.cs

@@ -5,6 +5,7 @@ using System.IO;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Models.EnvironmentDtos;
 using Jellyfin.Api.Models.EnvironmentDtos;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.IO;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
@@ -103,6 +104,11 @@ namespace Jellyfin.Api.Controllers
 
 
                 if (validatePathDto.ValidateWritable)
                 if (validatePathDto.ValidateWritable)
                 {
                 {
+                    if (validatePathDto.Path == null)
+                    {
+                        throw new ResourceNotFoundException(nameof(validatePathDto.Path));
+                    }
+
                     var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString());
                     var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString());
                     try
                     try
                     {
                     {

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

@@ -78,8 +78,8 @@ namespace Jellyfin.Api.Controllers
             var query = new InternalItemsQuery
             var query = new InternalItemsQuery
             {
             {
                 User = user,
                 User = user,
-                MediaTypes = (mediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
-                IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
+                MediaTypes = (mediaTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
+                IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
                 Recursive = true,
                 Recursive = true,
                 EnableTotalRecordCount = false,
                 EnableTotalRecordCount = false,
                 DtoOptions = new DtoOptions
                 DtoOptions = new DtoOptions
@@ -168,7 +168,7 @@ namespace Jellyfin.Api.Controllers
             var genreQuery = new InternalItemsQuery(user)
             var genreQuery = new InternalItemsQuery(user)
             {
             {
                 IncludeItemTypes =
                 IncludeItemTypes =
-                    (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
+                    (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
                 DtoOptions = new DtoOptions
                 DtoOptions = new DtoOptions
                 {
                 {
                     Fields = Array.Empty<ItemFields>(),
                     Fields = Array.Empty<ItemFields>(),

+ 17 - 134
Jellyfin.Api/Controllers/GenresController.cs

@@ -1,6 +1,5 @@
 using System;
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
-using System.Globalization;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
@@ -49,30 +48,16 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// <summary>
         /// Gets all genres from a given item, folder, or the entire library.
         /// Gets all genres from a given item, folder, or the entire library.
         /// </summary>
         /// </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="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="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">The search term.</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="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</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="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="includeItemTypes">Optional. If specified, results will be filtered in 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="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="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="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe 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="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="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="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -84,30 +69,16 @@ namespace Jellyfin.Api.Controllers
         [HttpGet]
         [HttpGet]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetGenres(
         public ActionResult<QueryResult<BaseItemDto>> GetGenres(
-            [FromQuery] double? minCommunityRating,
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
-            [FromQuery] string? genres,
-            [FromQuery] string? genreIds,
-            [FromQuery] string? officialRatings,
-            [FromQuery] string? tags,
-            [FromQuery] string? years,
-            [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
             [FromQuery] string? nameStartsWith,
@@ -115,45 +86,24 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages = true,
             [FromQuery] bool? enableImages = true,
             [FromQuery] bool enableTotalRecordCount = true)
             [FromQuery] bool enableTotalRecordCount = true)
         {
         {
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+                .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
 
 
-            User? user = null;
-            BaseItem parentItem;
+            User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null;
 
 
-            if (userId.HasValue && !userId.Equals(Guid.Empty))
-            {
-                user = _userManager.GetUserById(userId.Value);
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
-            }
-            else
-            {
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
-            }
+            var parentItem = _libraryManager.GetParentItem(parentId, userId);
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
                 ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
                 ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
                 IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
                 IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
-                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
                 StartIndex = startIndex,
                 StartIndex = startIndex,
                 Limit = limit,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, '|', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds),
-                StudioIds = RequestHelpers.GetGuids(studioIds),
-                Person = person,
-                PersonIds = RequestHelpers.GetGuids(personIds),
-                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
-                Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(),
-                MinCommunityRating = minCommunityRating,
                 DtoOptions = dtoOptions,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
                 SearchTerm = searchTerm,
                 EnableTotalRecordCount = enableTotalRecordCount
                 EnableTotalRecordCount = enableTotalRecordCount
@@ -171,87 +121,20 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
             }
             }
 
 
-            // Studios
-            if (!string.IsNullOrEmpty(studios))
+            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)))
             {
             {
-                query.StudioIds = studios.Split('|')
-                    .Select(i =>
-                    {
-                        try
-                        {
-                            return _libraryManager.GetStudio(i);
-                        }
-                        catch
-                        {
-                            return null;
-                        }
-                    }).Where(i => i != null)
-                    .Select(i => i!.Id)
-                    .ToArray();
+                result = _libraryManager.GetMusicGenres(query);
             }
             }
-
-            foreach (var filter in filters)
+            else
             {
             {
-                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;
-                }
+                result = _libraryManager.GetGenres(query);
             }
             }
 
 
-            var result = new QueryResult<(BaseItem, ItemCounts)>();
-
-            var dtos = result.Items.Select(i =>
-            {
-                var (baseItem, counts) = i;
-                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
-                if (!string.IsNullOrWhiteSpace(includeItemTypes))
-                {
-                    dto.ChildCount = counts.ItemCount;
-                    dto.ProgramCount = counts.ProgramCount;
-                    dto.SeriesCount = counts.SeriesCount;
-                    dto.EpisodeCount = counts.EpisodeCount;
-                    dto.MovieCount = counts.MovieCount;
-                    dto.TrailerCount = counts.TrailerCount;
-                    dto.AlbumCount = counts.AlbumCount;
-                    dto.SongCount = counts.SongCount;
-                    dto.ArtistCount = counts.ArtistCount;
-                }
-
-                return dto;
-            });
-
-            return new QueryResult<BaseItemDto>
-            {
-                Items = dtos.ToArray(),
-                TotalRecordCount = result.TotalRecordCount
-            };
+            var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
+            return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -293,7 +176,7 @@ namespace Jellyfin.Api.Controllers
             return _dtoService.GetBaseItemDto(item, dtoOptions);
             return _dtoService.GetBaseItemDto(item, dtoOptions);
         }
         }
 
 
-        private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions)
+        private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions)
             where T : BaseItem, new()
             where T : BaseItem, new()
         {
         {
             var result = libraryManager.GetItemList(new InternalItemsQuery
             var result = libraryManager.GetItemList(new InternalItemsQuery

+ 3 - 1
Jellyfin.Api/Controllers/HlsSegmentController.cs

@@ -8,6 +8,7 @@ using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.IO;
@@ -134,7 +135,8 @@ namespace Jellyfin.Api.Controllers
             var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath)
             var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath)
                 .FirstOrDefault(i =>
                 .FirstOrDefault(i =>
                     string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase)
                     string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase)
-                    && i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1);
+                    && i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1)
+                ?? throw new ResourceNotFoundException($"Provided path ({transcodeFolderPath}) is not valid.");
 
 
             return GetFileResult(file, playlistPath);
             return GetFileResult(file, playlistPath);
         }
         }

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

@@ -161,7 +161,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="theme">Theme to search.</param>
         /// <param name="theme">Theme to search.</param>
         /// <param name="name">File name to search for.</param>
         /// <param name="name">File name to search for.</param>
         /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
         /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
-        private ActionResult GetImageFile(string basePath, string? theme, string? name)
+        private ActionResult GetImageFile(string basePath, string theme, string? name)
         {
         {
             var themeFolder = Path.Combine(basePath, theme);
             var themeFolder = Path.Combine(basePath, theme);
             if (Directory.Exists(themeFolder))
             if (Directory.Exists(themeFolder))

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

@@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis;
 using System.Globalization;
 using System.Globalization;
 using System.IO;
 using System.IO;
 using System.Linq;
 using System.Linq;
+using System.Net.Mime;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Attributes;
@@ -1268,7 +1269,7 @@ namespace Jellyfin.Api.Controllers
                 Response.Headers.Add(key, value);
                 Response.Headers.Add(key, value);
             }
             }
 
 
-            Response.ContentType = imageContentType;
+            Response.ContentType = imageContentType ?? MediaTypeNames.Text.Plain;
             Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture));
             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.Vary, HeaderNames.Accept);
 
 

+ 29 - 35
Jellyfin.Api/Controllers/InstantMixController.cs

@@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
@@ -55,7 +56,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="id">The item id.</param>
         /// <param name="id">The item id.</param>
         /// <param name="userId">Optional. Filter by user id, and attach user data.</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="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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="enableImages">Optional. Include image information in output.</param>
         /// <param name="enableUserData">Optional. Include user data.</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="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
@@ -68,18 +69,17 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid id,
             [FromRoute, Required] Guid id,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
         {
             var item = _libraryManager.GetItemById(id);
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
             var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
             var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
@@ -92,7 +92,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="id">The item id.</param>
         /// <param name="id">The item id.</param>
         /// <param name="userId">Optional. Filter by user id, and attach user data.</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="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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="enableImages">Optional. Include image information in output.</param>
         /// <param name="enableUserData">Optional. Include user data.</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="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
@@ -105,18 +105,17 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid id,
             [FromRoute, Required] Guid id,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
         {
             var album = _libraryManager.GetItemById(id);
             var album = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
             var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions);
             var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions);
@@ -129,7 +128,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="id">The item id.</param>
         /// <param name="id">The item id.</param>
         /// <param name="userId">Optional. Filter by user id, and attach user data.</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="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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="enableImages">Optional. Include image information in output.</param>
         /// <param name="enableUserData">Optional. Include user data.</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="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
@@ -142,18 +141,17 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid id,
             [FromRoute, Required] Guid id,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
         {
             var playlist = (Playlist)_libraryManager.GetItemById(id);
             var playlist = (Playlist)_libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
             var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions);
             var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions);
@@ -166,7 +164,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="name">The genre name.</param>
         /// <param name="name">The genre name.</param>
         /// <param name="userId">Optional. Filter by user id, and attach user data.</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="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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="enableImages">Optional. Include image information in output.</param>
         /// <param name="enableUserData">Optional. Include user data.</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="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
@@ -179,17 +177,16 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] string name,
             [FromRoute, Required] string name,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
             var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions);
             var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions);
@@ -202,7 +199,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="id">The item id.</param>
         /// <param name="id">The item id.</param>
         /// <param name="userId">Optional. Filter by user id, and attach user data.</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="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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="enableImages">Optional. Include image information in output.</param>
         /// <param name="enableUserData">Optional. Include user data.</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="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
@@ -215,18 +212,17 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid id,
             [FromRoute, Required] Guid id,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
         {
             var item = _libraryManager.GetItemById(id);
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
             var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
             var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
@@ -239,7 +235,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="id">The item id.</param>
         /// <param name="id">The item id.</param>
         /// <param name="userId">Optional. Filter by user id, and attach user data.</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="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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="enableImages">Optional. Include image information in output.</param>
         /// <param name="enableUserData">Optional. Include user data.</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="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
@@ -252,18 +248,17 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid id,
             [FromRoute, Required] Guid id,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
         {
             var item = _libraryManager.GetItemById(id);
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
             var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
             var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
@@ -276,7 +271,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="id">The item id.</param>
         /// <param name="id">The item id.</param>
         /// <param name="userId">Optional. Filter by user id, and attach user data.</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="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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="enableImages">Optional. Include image information in output.</param>
         /// <param name="enableUserData">Optional. Include user data.</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="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
@@ -289,18 +284,17 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid id,
             [FromRoute, Required] Guid id,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
         {
             var item = _libraryManager.GetItemById(id);
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
             var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
             var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);

+ 10 - 2
Jellyfin.Api/Controllers/ItemLookupController.cs

@@ -334,10 +334,16 @@ namespace Jellyfin.Api.Controllers
         private async Task DownloadImage(string providerName, string url, Guid urlHash, string pointerCachePath)
         private async Task DownloadImage(string providerName, string url, Guid urlHash, string pointerCachePath)
         {
         {
             using var result = await _providerManager.GetSearchImage(providerName, url, CancellationToken.None).ConfigureAwait(false);
             using var result = await _providerManager.GetSearchImage(providerName, url, CancellationToken.None).ConfigureAwait(false);
+            if (result.Content.Headers.ContentType?.MediaType == null)
+            {
+                throw new ResourceNotFoundException(nameof(result.Content.Headers.ContentType));
+            }
+
             var ext = result.Content.Headers.ContentType.MediaType.Split('/')[^1];
             var ext = result.Content.Headers.ContentType.MediaType.Split('/')[^1];
             var fullCachePath = GetFullCachePath(urlHash + "." + ext);
             var fullCachePath = GetFullCachePath(urlHash + "." + ext);
 
 
-            Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath));
+            var directory = Path.GetDirectoryName(fullCachePath) ?? throw new ResourceNotFoundException($"Provided path ({fullCachePath}) is not valid.");
+            Directory.CreateDirectory(directory);
             using (var stream = result.Content)
             using (var stream = result.Content)
             {
             {
                 await using var fileStream = new FileStream(
                 await using var fileStream = new FileStream(
@@ -351,7 +357,9 @@ namespace Jellyfin.Api.Controllers
                 await stream.CopyToAsync(fileStream).ConfigureAwait(false);
                 await stream.CopyToAsync(fileStream).ConfigureAwait(false);
             }
             }
 
 
-            Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath));
+            var pointerCacheDirectory = Path.GetDirectoryName(pointerCachePath) ?? throw new ArgumentException($"Provided path ({pointerCachePath}) is not valid.", nameof(pointerCachePath));
+
+            Directory.CreateDirectory(pointerCacheDirectory);
             await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath).ConfigureAwait(false);
             await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath).ConfigureAwait(false);
         }
         }
 
 

+ 7 - 9
Jellyfin.Api/Controllers/ItemsController.cs

@@ -180,13 +180,13 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
             [FromQuery] string? sortOrder,
             [FromQuery] string? sortOrder,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] bool? isFavorite,
             [FromQuery] string? mediaTypes,
             [FromQuery] string? mediaTypes,
-            [FromQuery] ImageType[] imageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
             [FromQuery] string? sortBy,
             [FromQuery] string? sortBy,
             [FromQuery] bool? isPlayed,
             [FromQuery] bool? isPlayed,
             [FromQuery] string? genres,
             [FromQuery] string? genres,
@@ -195,7 +195,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,
             [FromQuery] string? personTypes,
@@ -234,8 +234,7 @@ namespace Jellyfin.Api.Controllers
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 
@@ -533,11 +532,11 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? mediaTypes,
             [FromQuery] string? mediaTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] bool enableTotalRecordCount = true,
             [FromQuery] bool enableTotalRecordCount = true,
@@ -545,8 +544,7 @@ namespace Jellyfin.Api.Controllers
         {
         {
             var user = _userManager.GetUserById(userId);
             var user = _userManager.GetUserById(userId);
             var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
             var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 

+ 68 - 98
Jellyfin.Api/Controllers/LibraryController.cs

@@ -12,6 +12,7 @@ using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.LibraryDtos;
 using Jellyfin.Api.Models.LibraryDtos;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Progress;
 using MediaBrowser.Common.Progress;
@@ -455,7 +456,7 @@ namespace Jellyfin.Api.Controllers
                 : null;
                 : null;
 
 
             var dtoOptions = new DtoOptions().AddClientFields(Request);
             var dtoOptions = new DtoOptions().AddClientFields(Request);
-            BaseItem parent = item.GetParent();
+            BaseItem? parent = item.GetParent();
 
 
             while (parent != null)
             while (parent != null)
             {
             {
@@ -466,7 +467,7 @@ namespace Jellyfin.Api.Controllers
 
 
                 baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user));
                 baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user));
 
 
-                parent = parent.GetParent();
+                parent = parent?.GetParent();
             }
             }
 
 
             return baseItemDtos;
             return baseItemDtos;
@@ -680,12 +681,12 @@ namespace Jellyfin.Api.Controllers
         /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
         /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
         /// <response code="200">Similar items returned.</response>
         /// <response code="200">Similar items returned.</response>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns>
-        [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists2")]
+        [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")]
         [HttpGet("Items/{itemId}/Similar")]
         [HttpGet("Items/{itemId}/Similar")]
-        [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums2")]
-        [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows2")]
-        [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies2")]
-        [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers2")]
+        [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums")]
+        [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows")]
+        [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies")]
+        [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
         public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
@@ -693,7 +694,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? excludeArtistIds,
             [FromQuery] string? excludeArtistIds,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string? fields)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
         {
         {
             var item = itemId.Equals(Guid.Empty)
             var item = itemId.Equals(Guid.Empty)
                 ? (!userId.Equals(Guid.Empty)
                 ? (!userId.Equals(Guid.Empty)
@@ -701,33 +702,71 @@ namespace Jellyfin.Api.Controllers
                     : _libraryManager.RootFolder)
                     : _libraryManager.RootFolder)
                 : _libraryManager.GetItemById(itemId);
                 : _libraryManager.GetItemById(itemId);
 
 
+            if (item is Episode || (item is IItemByName && !(item is MusicArtist)))
+            {
+                return new QueryResult<BaseItemDto>();
+            }
+
+            var user = userId.HasValue && !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId.Value)
+                : null;
+            var dtoOptions = new DtoOptions { Fields = fields }
+                .AddClientFields(Request);
+
             var program = item as IHasProgramAttributes;
             var program = item as IHasProgramAttributes;
-            var isMovie = item is MediaBrowser.Controller.Entities.Movies.Movie || (program != null && program.IsMovie) || item is Trailer;
-            if (program != null && program.IsSeries)
+            bool? isMovie = item is Movie || (program != null && program.IsMovie) || item is Trailer;
+            bool? isSeries = item is Series || (program != null && program.IsSeries);
+
+            var includeItemTypes = new List<string>();
+            if (isMovie.Value)
             {
             {
-                return GetSimilarItemsResult(
-                    item,
-                    excludeArtistIds,
-                    userId,
-                    limit,
-                    fields,
-                    new[] { nameof(Series) },
-                    false);
+                includeItemTypes.Add(nameof(Movie));
+                if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+                {
+                    includeItemTypes.Add(nameof(Trailer));
+                    includeItemTypes.Add(nameof(LiveTvProgram));
+                }
+            }
+            else if (isSeries.Value)
+            {
+                includeItemTypes.Add(nameof(Series));
+            }
+            else
+            {
+                // For non series and movie types these columns are typically null
+                isSeries = null;
+                isMovie = null;
+                includeItemTypes.Add(item.GetType().Name);
             }
             }
 
 
-            if (item is MediaBrowser.Controller.Entities.TV.Episode || (item is IItemByName && !(item is MusicArtist)))
+            var query = new InternalItemsQuery(user)
             {
             {
-                return new QueryResult<BaseItemDto>();
+                Limit = limit,
+                IncludeItemTypes = includeItemTypes.ToArray(),
+                IsMovie = isMovie,
+                IsSeries = isSeries,
+                SimilarTo = item,
+                DtoOptions = dtoOptions,
+                EnableTotalRecordCount = !isMovie ?? true,
+                EnableGroupByMetadataKey = isMovie ?? false,
+                MinSimilarityScore = 2 // A remnant from album/artist scoring
+            };
+
+            // ExcludeArtistIds
+            if (!string.IsNullOrEmpty(excludeArtistIds))
+            {
+                query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
             }
             }
 
 
-            return GetSimilarItemsResult(
-                item,
-                excludeArtistIds,
-                userId,
-                limit,
-                fields,
-                new[] { item.GetType().Name },
-                isMovie);
+            List<BaseItem> itemsResult = _libraryManager.GetItemList(query);
+
+            var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = returnList,
+                TotalRecordCount = itemsResult.Count
+            };
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -854,7 +893,7 @@ namespace Jellyfin.Api.Controllers
             return _libraryManager.GetItemsResult(query).TotalRecordCount;
             return _libraryManager.GetItemsResult(query).TotalRecordCount;
         }
         }
 
 
-        private BaseItem TranslateParentItem(BaseItem item, User user)
+        private BaseItem? TranslateParentItem(BaseItem item, User user)
         {
         {
             return item.GetParent() is AggregateFolder
             return item.GetParent() is AggregateFolder
                 ? _libraryManager.GetUserRootFolder().GetChildren(user, true)
                 ? _libraryManager.GetUserRootFolder().GetChildren(user, true)
@@ -880,75 +919,6 @@ namespace Jellyfin.Api.Controllers
             }
             }
         }
         }
 
 
-        private QueryResult<BaseItemDto> GetSimilarItemsResult(
-            BaseItem item,
-            string? excludeArtistIds,
-            Guid? userId,
-            int? limit,
-            string? fields,
-            string[] includeItemTypes,
-            bool isMovie)
-        {
-            var user = userId.HasValue && !userId.Equals(Guid.Empty)
-                ? _userManager.GetUserById(userId.Value)
-                : null;
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
-                .AddClientFields(Request);
-
-            var query = new InternalItemsQuery(user)
-            {
-                Limit = limit,
-                IncludeItemTypes = includeItemTypes,
-                IsMovie = isMovie,
-                SimilarTo = item,
-                DtoOptions = dtoOptions,
-                EnableTotalRecordCount = !isMovie,
-                EnableGroupByMetadataKey = isMovie
-            };
-
-            // ExcludeArtistIds
-            if (!string.IsNullOrEmpty(excludeArtistIds))
-            {
-                query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
-            }
-
-            List<BaseItem> itemsResult;
-
-            if (isMovie)
-            {
-                var itemTypes = new List<string> { nameof(MediaBrowser.Controller.Entities.Movies.Movie) };
-                if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
-                {
-                    itemTypes.Add(nameof(Trailer));
-                    itemTypes.Add(nameof(LiveTvProgram));
-                }
-
-                query.IncludeItemTypes = itemTypes.ToArray();
-                itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList();
-            }
-            else if (item is MusicArtist)
-            {
-                query.IncludeItemTypes = Array.Empty<string>();
-
-                itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList();
-            }
-            else
-            {
-                itemsResult = _libraryManager.GetItemList(query);
-            }
-
-            var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user);
-
-            var result = new QueryResult<BaseItemDto>
-            {
-                Items = returnList,
-                TotalRecordCount = itemsResult.Count
-            };
-
-            return result;
-        }
-
         private static string[] GetRepresentativeItemTypes(string? contentType)
         private static string[] GetRepresentativeItemTypes(string? contentType)
         {
         {
             return contentType switch
             return contentType switch

+ 23 - 27
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -14,6 +14,7 @@ using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.LiveTvDtos;
 using Jellyfin.Api.Models.LiveTvDtos;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Common;
 using MediaBrowser.Common;
@@ -118,7 +119,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableImages">Optional. Include image information in output.</param>
         /// <param name="enableImages">Optional. Include image information in output.</param>
         /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</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="enableImageTypes">"Optional. The image types to include in the output.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="enableUserData">Optional. Include user data.</param>
         /// <param name="enableUserData">Optional. Include user data.</param>
         /// <param name="sortBy">Optional. Key to sort by.</param>
         /// <param name="sortBy">Optional. Key to sort by.</param>
         /// <param name="sortOrder">Optional. Sort order.</param>
         /// <param name="sortOrder">Optional. Sort order.</param>
@@ -146,16 +147,15 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isDisliked,
             [FromQuery] bool? isDisliked,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] string? sortBy,
             [FromQuery] string? sortBy,
             [FromQuery] SortOrder? sortOrder,
             [FromQuery] SortOrder? sortOrder,
             [FromQuery] bool enableFavoriteSorting = false,
             [FromQuery] bool enableFavoriteSorting = false,
             [FromQuery] bool addCurrentProgram = true)
             [FromQuery] bool addCurrentProgram = true)
         {
         {
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 
@@ -239,7 +239,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableImages">Optional. Include image information in output.</param>
         /// <param name="enableImages">Optional. Include image information in output.</param>
         /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</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="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="enableUserData">Optional. Include user data.</param>
         /// <param name="enableUserData">Optional. Include user data.</param>
         /// <param name="isMovie">Optional. Filter for movies.</param>
         /// <param name="isMovie">Optional. Filter for movies.</param>
         /// <param name="isSeries">Optional. Filter for series.</param>
         /// <param name="isSeries">Optional. Filter for series.</param>
@@ -263,8 +263,8 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? seriesTimerId,
             [FromQuery] string? seriesTimerId,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? isMovie,
             [FromQuery] bool? isMovie,
             [FromQuery] bool? isSeries,
             [FromQuery] bool? isSeries,
@@ -274,8 +274,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isLibraryItem,
             [FromQuery] bool? isLibraryItem,
             [FromQuery] bool enableTotalRecordCount = true)
             [FromQuery] bool enableTotalRecordCount = true)
         {
         {
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 
@@ -296,7 +295,7 @@ namespace Jellyfin.Api.Controllers
                 IsKids = isKids,
                 IsKids = isKids,
                 IsSports = isSports,
                 IsSports = isSports,
                 IsLibraryItem = isLibraryItem,
                 IsLibraryItem = isLibraryItem,
-                Fields = RequestHelpers.GetItemFields(fields),
+                Fields = fields,
                 ImageTypeLimit = imageTypeLimit,
                 ImageTypeLimit = imageTypeLimit,
                 EnableImages = enableImages
                 EnableImages = enableImages
             }, dtoOptions);
             }, dtoOptions);
@@ -316,7 +315,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableImages">Optional. Include image information in output.</param>
         /// <param name="enableImages">Optional. Include image information in output.</param>
         /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</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="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="enableUserData">Optional. Include user data.</param>
         /// <param name="enableUserData">Optional. Include user data.</param>
         /// <param name="enableTotalRecordCount">Optional. Return total record count.</param>
         /// <param name="enableTotalRecordCount">Optional. Return total record count.</param>
         /// <response code="200">Live tv recordings returned.</response>
         /// <response code="200">Live tv recordings returned.</response>
@@ -350,8 +349,8 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? seriesTimerId,
             [FromQuery] string? seriesTimerId,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool enableTotalRecordCount = true)
             [FromQuery] bool enableTotalRecordCount = true)
         {
         {
@@ -530,7 +529,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableUserData">Optional. Include user data.</param>
         /// <param name="enableUserData">Optional. Include user data.</param>
         /// <param name="seriesTimerId">Optional. Filter by series timer id.</param>
         /// <param name="seriesTimerId">Optional. Filter by series timer id.</param>
         /// <param name="librarySeriesId">Optional. Filter by library series id.</param>
         /// <param name="librarySeriesId">Optional. Filter by library series id.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="enableTotalRecordCount">Retrieve total record count.</param>
         /// <param name="enableTotalRecordCount">Retrieve total record count.</param>
         /// <response code="200">Live tv epgs returned.</response>
         /// <response code="200">Live tv epgs returned.</response>
         /// <returns>
         /// <returns>
@@ -561,11 +560,11 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? genreIds,
             [FromQuery] string? genreIds,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] string? seriesTimerId,
             [FromQuery] string? seriesTimerId,
             [FromQuery] Guid? librarySeriesId,
             [FromQuery] Guid? librarySeriesId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool enableTotalRecordCount = true)
             [FromQuery] bool enableTotalRecordCount = true)
         {
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -606,8 +605,7 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
             }
             }
 
 
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
             return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
             return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
@@ -662,8 +660,7 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
             }
             }
 
 
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(body.Fields)
+            var dtoOptions = new DtoOptions { Fields = body.Fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes);
                 .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes);
             return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
             return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
@@ -685,7 +682,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</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="enableImageTypes">Optional. The image types to include in the output.</param>
         /// <param name="genreIds">The genres to return guide information for.</param>
         /// <param name="genreIds">The genres to return guide information for.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="enableUserData">Optional. include user data.</param>
         /// <param name="enableUserData">Optional. include user data.</param>
         /// <param name="enableTotalRecordCount">Retrieve total record count.</param>
         /// <param name="enableTotalRecordCount">Retrieve total record count.</param>
         /// <response code="200">Recommended epgs returned.</response>
         /// <response code="200">Recommended epgs returned.</response>
@@ -705,9 +702,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isSports,
             [FromQuery] bool? isSports,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? genreIds,
             [FromQuery] string? genreIds,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool enableTotalRecordCount = true)
             [FromQuery] bool enableTotalRecordCount = true)
         {
         {
@@ -729,8 +726,7 @@ namespace Jellyfin.Api.Controllers
                 GenreIds = RequestHelpers.GetGuids(genreIds)
                 GenreIds = RequestHelpers.GetGuids(genreIds)
             };
             };
 
 
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
             return _liveTvManager.GetRecommendedPrograms(query, dtoOptions, CancellationToken.None);
             return _liveTvManager.GetRecommendedPrograms(query, dtoOptions, CancellationToken.None);
@@ -1077,7 +1073,7 @@ namespace Jellyfin.Api.Controllers
             var client = _httpClientFactory.CreateClient(NamedClient.Default);
             var client = _httpClientFactory.CreateClient(NamedClient.Default);
             // https://json.schedulesdirect.org/20141201/available/countries
             // https://json.schedulesdirect.org/20141201/available/countries
             // Can't dispose the response as it's required up the call chain.
             // Can't dispose the response as it's required up the call chain.
-            var response = await client.GetAsync("https://json.schedulesdirect.org/20141201/available/countries")
+            var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries"))
                 .ConfigureAwait(false);
                 .ConfigureAwait(false);
 
 
             return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json);
             return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json);

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

@@ -4,6 +4,7 @@ using System.Globalization;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
@@ -65,15 +66,14 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
         public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] int categoryLimit = 5,
             [FromQuery] int categoryLimit = 5,
             [FromQuery] int itemLimit = 8)
             [FromQuery] int itemLimit = 8)
         {
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
                 : null;
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request);
                 .AddClientFields(Request);
 
 
             var categories = new List<RecommendationDto>();
             var categories = new List<RecommendationDto>();

+ 12 - 138
Jellyfin.Api/Controllers/MusicGenresController.cs

@@ -1,6 +1,5 @@
 using System;
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
-using System.Globalization;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
@@ -49,30 +48,16 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// <summary>
         /// Gets all music genres from a given item, folder, or the entire library.
         /// Gets all music genres from a given item, folder, or the entire library.
         /// </summary>
         /// </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="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="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">The search term.</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="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</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="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="includeItemTypes">Optional. If specified, results will be filtered in 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="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="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="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe 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="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="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="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -82,31 +67,18 @@ namespace Jellyfin.Api.Controllers
         /// <response code="200">Music genres returned.</response>
         /// <response code="200">Music genres returned.</response>
         /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns>
         /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns>
         [HttpGet]
         [HttpGet]
+        [Obsolete("Use GetGenres instead")]
         public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres(
         public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres(
-            [FromQuery] double? minCommunityRating,
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
-            [FromQuery] string? genres,
-            [FromQuery] string? genreIds,
-            [FromQuery] string? officialRatings,
-            [FromQuery] string? tags,
-            [FromQuery] string? years,
-            [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
             [FromQuery] string? nameStartsWith,
@@ -114,45 +86,24 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages = true,
             [FromQuery] bool? enableImages = true,
             [FromQuery] bool enableTotalRecordCount = true)
             [FromQuery] bool enableTotalRecordCount = true)
         {
         {
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
-                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+                .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
 
 
-            User? user = null;
-            BaseItem parentItem;
+            User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null;
 
 
-            if (userId.HasValue && !userId.Equals(Guid.Empty))
-            {
-                user = _userManager.GetUserById(userId.Value);
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
-            }
-            else
-            {
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
-            }
+            var parentItem = _libraryManager.GetParentItem(parentId, userId);
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
                 ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
                 ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
                 IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
                 IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
-                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
                 StartIndex = startIndex,
                 StartIndex = startIndex,
                 Limit = limit,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, '|', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds),
-                StudioIds = RequestHelpers.GetGuids(studioIds),
-                Person = person,
-                PersonIds = RequestHelpers.GetGuids(personIds),
-                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
-                Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(),
-                MinCommunityRating = minCommunityRating,
                 DtoOptions = dtoOptions,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
                 SearchTerm = searchTerm,
                 EnableTotalRecordCount = enableTotalRecordCount
                 EnableTotalRecordCount = enableTotalRecordCount
@@ -170,87 +121,10 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
             }
             }
 
 
-            // Studios
-            if (!string.IsNullOrEmpty(studios))
-            {
-                query.StudioIds = studios.Split('|')
-                    .Select(i =>
-                    {
-                        try
-                        {
-                            return _libraryManager.GetStudio(i);
-                        }
-                        catch
-                        {
-                            return null;
-                        }
-                    }).Where(i => i != null)
-                    .Select(i => i!.Id)
-                    .ToArray();
-            }
-
-            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.GetMusicGenres(query);
             var result = _libraryManager.GetMusicGenres(query);
 
 
-            var dtos = result.Items.Select(i =>
-            {
-                var (baseItem, counts) = i;
-                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
-                if (!string.IsNullOrWhiteSpace(includeItemTypes))
-                {
-                    dto.ChildCount = counts.ItemCount;
-                    dto.ProgramCount = counts.ProgramCount;
-                    dto.SeriesCount = counts.SeriesCount;
-                    dto.EpisodeCount = counts.EpisodeCount;
-                    dto.MovieCount = counts.MovieCount;
-                    dto.TrailerCount = counts.TrailerCount;
-                    dto.AlbumCount = counts.AlbumCount;
-                    dto.SongCount = counts.SongCount;
-                    dto.ArtistCount = counts.ArtistCount;
-                }
-
-                return dto;
-            });
-
-            return new QueryResult<BaseItemDto>
-            {
-                Items = dtos.ToArray(),
-                TotalRecordCount = result.TotalRecordCount
-            };
+            var shouldIncludeItemTypes = !string.IsNullOrWhiteSpace(includeItemTypes);
+            return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -265,7 +139,7 @@ namespace Jellyfin.Api.Controllers
         {
         {
             var dtoOptions = new DtoOptions().AddClientFields(Request);
             var dtoOptions = new DtoOptions().AddClientFields(Request);
 
 
-            MusicGenre item;
+            MusicGenre? item;
 
 
             if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1)
             if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1)
             {
             {
@@ -286,7 +160,7 @@ namespace Jellyfin.Api.Controllers
             return _dtoService.GetBaseItemDto(item, dtoOptions);
             return _dtoService.GetBaseItemDto(item, dtoOptions);
         }
         }
 
 
-        private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions)
+        private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions)
             where T : BaseItem, new()
             where T : BaseItem, new()
         {
         {
             var result = libraryManager.GetItemList(new InternalItemsQuery
             var result = libraryManager.GetItemList(new InternalItemsQuery

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

@@ -54,6 +54,11 @@ namespace Jellyfin.Api.Controllers
                     string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid))
                     string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid))
                 .FirstOrDefault();
                 .FirstOrDefault();
 
 
+            if (result == null)
+            {
+                return NotFound();
+            }
+
             return result;
             return result;
         }
         }
 
 
@@ -149,12 +154,13 @@ namespace Jellyfin.Api.Controllers
         /// <param name="repositoryInfos">The list of package repositories.</param>
         /// <param name="repositoryInfos">The list of package repositories.</param>
         /// <response code="204">Package repositories saved.</response>
         /// <response code="204">Package repositories saved.</response>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpOptions("Repositories")]
+        [HttpPost("Repositories")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult SetRepositories([FromBody] List<RepositoryInfo> repositoryInfos)
         public ActionResult SetRepositories([FromBody] List<RepositoryInfo> repositoryInfos)
         {
         {
             _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos;
             _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos;
+            _serverConfigurationManager.SaveConfiguration();
             return NoContent();
             return NoContent();
         }
         }
     }
     }

+ 28 - 168
Jellyfin.Api/Controllers/PersonsController.cs

@@ -1,6 +1,5 @@
 using System;
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
-using System.Globalization;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
@@ -28,6 +27,7 @@ namespace Jellyfin.Api.Controllers
         private readonly ILibraryManager _libraryManager;
         private readonly ILibraryManager _libraryManager;
         private readonly IDtoService _dtoService;
         private readonly IDtoService _dtoService;
         private readonly IUserManager _userManager;
         private readonly IUserManager _userManager;
+        private readonly IUserDataManager _userDataManager;
 
 
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="PersonsController"/> class.
         /// Initializes a new instance of the <see cref="PersonsController"/> class.
@@ -35,221 +35,81 @@ namespace Jellyfin.Api.Controllers
         /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
         /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
         /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
         /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
         /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
         /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
         public PersonsController(
         public PersonsController(
             ILibraryManager libraryManager,
             ILibraryManager libraryManager,
             IDtoService dtoService,
             IDtoService dtoService,
-            IUserManager userManager)
+            IUserManager userManager,
+            IUserDataManager userDataManager)
         {
         {
             _libraryManager = libraryManager;
             _libraryManager = libraryManager;
             _dtoService = dtoService;
             _dtoService = dtoService;
             _userManager = userManager;
             _userManager = userManager;
+            _userDataManager = userDataManager;
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Gets all persons from a given item, folder, or the entire library.
+        /// Gets all persons.
         /// </summary>
         /// </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="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">The search term.</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. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
-        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered 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="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
-        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
-        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
-        /// <param name="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="fields">Optional. Specify additional fields of information to return in the output.</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. userId is required.</param>
         /// <param name="enableUserData">Optional, include user data.</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="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="enableImageTypes">Optional. The image types to include in the output.</param>
-        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
-        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
-        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
-        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
-        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+        /// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param>
+        /// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param>
+        /// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param>
         /// <param name="userId">User id.</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="enableImages">Optional, include image information in output.</param>
         /// <param name="enableImages">Optional, include image information in output.</param>
-        /// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
         /// <response code="200">Persons returned.</response>
         /// <response code="200">Persons returned.</response>
         /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns>
         /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns>
         [HttpGet]
         [HttpGet]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetPersons(
         public ActionResult<QueryResult<BaseItemDto>> GetPersons(
-            [FromQuery] double? minCommunityRating,
-            [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
-            [FromQuery] string? parentId,
-            [FromQuery] string? fields,
-            [FromQuery] string? excludeItemTypes,
-            [FromQuery] string? includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
-            [FromQuery] string? genres,
-            [FromQuery] string? genreIds,
-            [FromQuery] string? officialRatings,
-            [FromQuery] string? tags,
-            [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+            [FromQuery] string? excludePersonTypes,
             [FromQuery] string? personTypes,
             [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [FromQuery] string? appearsInItemId,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
-            [FromQuery] string? nameStartsWithOrGreater,
-            [FromQuery] string? nameStartsWith,
-            [FromQuery] string? nameLessThan,
-            [FromQuery] bool? enableImages = true,
-            [FromQuery] bool enableTotalRecordCount = true)
+            [FromQuery] bool? enableImages = true)
         {
         {
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 
             User? user = null;
             User? user = null;
-            BaseItem parentItem;
 
 
             if (userId.HasValue && !userId.Equals(Guid.Empty))
             if (userId.HasValue && !userId.Equals(Guid.Empty))
             {
             {
                 user = _userManager.GetUserById(userId.Value);
                 user = _userManager.GetUserById(userId.Value);
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
-            }
-            else
-            {
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
             }
             }
 
 
-            var query = new InternalItemsQuery(user)
+            var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite);
+            var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery
             {
             {
-                ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
-                IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
-                MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
-                StartIndex = startIndex,
-                Limit = limit,
-                IsFavorite = isFavorite,
-                NameLessThan = nameLessThan,
-                NameStartsWith = nameStartsWith,
-                NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, '|', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds),
-                StudioIds = RequestHelpers.GetGuids(studioIds),
-                Person = person,
-                PersonIds = RequestHelpers.GetGuids(personIds),
                 PersonTypes = RequestHelpers.Split(personTypes, ',', true),
                 PersonTypes = RequestHelpers.Split(personTypes, ',', true),
-                Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(),
-                MinCommunityRating = minCommunityRating,
-                DtoOptions = dtoOptions,
-                SearchTerm = searchTerm,
-                EnableTotalRecordCount = enableTotalRecordCount
-            };
-
-            if (!string.IsNullOrWhiteSpace(parentId))
-            {
-                if (parentItem is Folder)
-                {
-                    query.AncestorIds = new[] { new Guid(parentId) };
-                }
-                else
-                {
-                    query.ItemIds = new[] { new Guid(parentId) };
-                }
-            }
-
-            // Studios
-            if (!string.IsNullOrEmpty(studios))
-            {
-                query.StudioIds = studios.Split('|')
-                    .Select(i =>
-                    {
-                        try
-                        {
-                            return _libraryManager.GetStudio(i);
-                        }
-                        catch
-                        {
-                            return null;
-                        }
-                    }).Where(i => i != null)
-                    .Select(i => i!.Id)
-                    .ToArray();
-            }
-
-            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 = new QueryResult<(BaseItem, ItemCounts)>();
-
-            var dtos = result.Items.Select(i =>
-            {
-                var (baseItem, counts) = i;
-                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
-                if (!string.IsNullOrWhiteSpace(includeItemTypes))
-                {
-                    dto.ChildCount = counts.ItemCount;
-                    dto.ProgramCount = counts.ProgramCount;
-                    dto.SeriesCount = counts.SeriesCount;
-                    dto.EpisodeCount = counts.EpisodeCount;
-                    dto.MovieCount = counts.MovieCount;
-                    dto.TrailerCount = counts.TrailerCount;
-                    dto.AlbumCount = counts.AlbumCount;
-                    dto.SongCount = counts.SongCount;
-                    dto.ArtistCount = counts.ArtistCount;
-                }
-
-                return dto;
+                ExcludePersonTypes = RequestHelpers.Split(excludePersonTypes, ',', true),
+                NameContains = searchTerm,
+                User = user,
+                IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite,
+                AppearsInItemId = string.IsNullOrEmpty(appearsInItemId) ? Guid.Empty : Guid.Parse(appearsInItemId),
+                Limit = limit ?? 0
             });
             });
 
 
             return new QueryResult<BaseItemDto>
             return new QueryResult<BaseItemDto>
             {
             {
-                Items = dtos.ToArray(),
-                TotalRecordCount = result.TotalRecordCount
+                Items = peopleItems.Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)).ToArray(),
+                TotalRecordCount = peopleItems.Count
             };
             };
         }
         }
 
 

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

@@ -5,6 +5,7 @@ using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.PlaylistDtos;
 using Jellyfin.Api.Models.PlaylistDtos;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
@@ -134,7 +135,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="userId">User id.</param>
         /// <param name="userId">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="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="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</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="enableImages">Optional. Include image information in output.</param>
         /// <param name="enableUserData">Optional. Include user data.</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="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
@@ -148,11 +149,11 @@ namespace Jellyfin.Api.Controllers
             [FromQuery, Required] Guid userId,
             [FromQuery, Required] Guid userId,
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes)
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
         {
         {
             var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
             var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
             if (playlist == null)
             if (playlist == null)
@@ -176,8 +177,7 @@ namespace Jellyfin.Api.Controllers
                 items = items.Take(limit.Value).ToArray();
                 items = items.Take(limit.Value).ToArray();
             }
             }
 
 
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 

+ 14 - 6
Jellyfin.Api/Controllers/RemoteImageController.cs

@@ -157,9 +157,9 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
         [ProducesImageFile]
-        public async Task<ActionResult> GetRemoteImage([FromQuery, Required] string imageUrl)
+        public async Task<ActionResult> GetRemoteImage([FromQuery, Required] Uri imageUrl)
         {
         {
-            var urlHash = imageUrl.GetMD5();
+            var urlHash = imageUrl.ToString().GetMD5();
             var pointerCachePath = GetFullCachePath(urlHash.ToString());
             var pointerCachePath = GetFullCachePath(urlHash.ToString());
 
 
             string? contentPath = null;
             string? contentPath = null;
@@ -245,17 +245,25 @@ namespace Jellyfin.Api.Controllers
         /// <param name="urlHash">The URL hash.</param>
         /// <param name="urlHash">The URL hash.</param>
         /// <param name="pointerCachePath">The pointer cache path.</param>
         /// <param name="pointerCachePath">The pointer cache path.</param>
         /// <returns>Task.</returns>
         /// <returns>Task.</returns>
-        private async Task DownloadImage(string url, Guid urlHash, string pointerCachePath)
+        private async Task DownloadImage(Uri url, Guid urlHash, string pointerCachePath)
         {
         {
             var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
             var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
             using var response = await httpClient.GetAsync(url).ConfigureAwait(false);
             using var response = await httpClient.GetAsync(url).ConfigureAwait(false);
-            var ext = response.Content.Headers.ContentType.MediaType.Split('/').Last();
+            if (response.Content.Headers.ContentType?.MediaType == null)
+            {
+                throw new ResourceNotFoundException(nameof(response.Content.Headers.ContentType));
+            }
+
+            var ext = response.Content.Headers.ContentType.MediaType.Split('/')[^1];
             var fullCachePath = GetFullCachePath(urlHash + "." + ext);
             var fullCachePath = GetFullCachePath(urlHash + "." + ext);
 
 
-            Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath));
+            var fullCacheDirectory = Path.GetDirectoryName(fullCachePath) ?? throw new ResourceNotFoundException($"Provided path ({fullCachePath}) is not valid.");
+            Directory.CreateDirectory(fullCacheDirectory);
             await using var fileStream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
             await using var fileStream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
             await response.Content.CopyToAsync(fileStream).ConfigureAwait(false);
             await response.Content.CopyToAsync(fileStream).ConfigureAwait(false);
-            Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath));
+
+            var pointerCacheDirectory = Path.GetDirectoryName(pointerCachePath) ?? throw new ArgumentException($"Provided path ({pointerCachePath}) is not valid.", nameof(pointerCachePath));
+            Directory.CreateDirectory(pointerCacheDirectory);
             await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath, CancellationToken.None)
             await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath, CancellationToken.None)
                 .ConfigureAwait(false);
                 .ConfigureAwait(false);
         }
         }

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

@@ -260,7 +260,7 @@ namespace Jellyfin.Api.Controllers
             }
             }
         }
         }
 
 
-        private T GetParentWithImage<T>(BaseItem item, ImageType type)
+        private T? GetParentWithImage<T>(BaseItem item, ImageType type)
             where T : BaseItem
             where T : BaseItem
         {
         {
             return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type));
             return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type));

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

@@ -1,6 +1,5 @@
 using System;
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
-using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
@@ -47,30 +46,17 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// <summary>
         /// Gets all studios from a given item, folder, or the entire library.
         /// Gets all studios from a given item, folder, or the entire library.
         /// </summary>
         /// </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="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="limit">Optional. The maximum number of records to return.</param>
         /// <param name="searchTerm">Optional. Search term.</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="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</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="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="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="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="enableUserData">Optional, include user data.</param>
         /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</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="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="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="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="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -82,30 +68,17 @@ namespace Jellyfin.Api.Controllers
         [HttpGet]
         [HttpGet]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetStudios(
         public ActionResult<QueryResult<BaseItemDto>> GetStudios(
-            [FromQuery] double? minCommunityRating,
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
-            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] bool? isFavorite,
-            [FromQuery] string? mediaTypes,
-            [FromQuery] string? genres,
-            [FromQuery] string? genreIds,
-            [FromQuery] string? officialRatings,
-            [FromQuery] string? tags,
-            [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
-            [FromQuery] string? person,
-            [FromQuery] string? personIds,
-            [FromQuery] string? personTypes,
-            [FromQuery] string? studios,
-            [FromQuery] string? studioIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWithOrGreater,
             [FromQuery] string? nameStartsWith,
             [FromQuery] string? nameStartsWith,
@@ -113,49 +86,27 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages = true,
             [FromQuery] bool? enableImages = true,
             [FromQuery] bool enableTotalRecordCount = true)
             [FromQuery] bool enableTotalRecordCount = true)
         {
         {
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 
-            User? user = null;
-            BaseItem parentItem;
+            User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null;
 
 
-            if (userId.HasValue && !userId.Equals(Guid.Empty))
-            {
-                user = _userManager.GetUserById(userId.Value);
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
-            }
-            else
-            {
-                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
-            }
+            var parentItem = _libraryManager.GetParentItem(parentId, userId);
 
 
             var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
             var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
             var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
             var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
-            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
 
 
             var query = new InternalItemsQuery(user)
             var query = new InternalItemsQuery(user)
             {
             {
                 ExcludeItemTypes = excludeItemTypesArr,
                 ExcludeItemTypes = excludeItemTypesArr,
                 IncludeItemTypes = includeItemTypesArr,
                 IncludeItemTypes = includeItemTypesArr,
-                MediaTypes = mediaTypesArr,
                 StartIndex = startIndex,
                 StartIndex = startIndex,
                 Limit = limit,
                 Limit = limit,
                 IsFavorite = isFavorite,
                 IsFavorite = isFavorite,
                 NameLessThan = nameLessThan,
                 NameLessThan = nameLessThan,
                 NameStartsWith = nameStartsWith,
                 NameStartsWith = nameStartsWith,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
                 NameStartsWithOrGreater = nameStartsWithOrGreater,
-                Tags = RequestHelpers.Split(tags, '|', true),
-                OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
-                Genres = RequestHelpers.Split(genres, '|', true),
-                GenreIds = RequestHelpers.GetGuids(genreIds),
-                StudioIds = RequestHelpers.GetGuids(studioIds),
-                Person = person,
-                PersonIds = RequestHelpers.GetGuids(personIds),
-                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
-                Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
-                MinCommunityRating = minCommunityRating,
                 DtoOptions = dtoOptions,
                 DtoOptions = dtoOptions,
                 SearchTerm = searchTerm,
                 SearchTerm = searchTerm,
                 EnableTotalRecordCount = enableTotalRecordCount
                 EnableTotalRecordCount = enableTotalRecordCount
@@ -173,84 +124,9 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
             }
             }
 
 
-            // Studios
-            if (!string.IsNullOrEmpty(studios))
-            {
-                query.StudioIds = studios.Split('|').Select(i =>
-                {
-                    try
-                    {
-                        return _libraryManager.GetStudio(i);
-                    }
-                    catch
-                    {
-                        return null;
-                    }
-                }).Where(i => i != null).Select(i => i!.Id)
-                    .ToArray();
-            }
-
-            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 = new QueryResult<(BaseItem, ItemCounts)>();
-            var dtos = result.Items.Select(i =>
-            {
-                var (baseItem, itemCounts) = i;
-                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
-
-                if (!string.IsNullOrWhiteSpace(includeItemTypes))
-                {
-                    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;
-            });
-
-            return new QueryResult<BaseItemDto>
-            {
-                Items = dtos.ToArray(),
-                TotalRecordCount = result.TotalRecordCount
-            };
+            var result = _libraryManager.GetStudios(query);
+            var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
+            return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
         }
         }
 
 
         /// <summary>
         /// <summary>

+ 125 - 0
Jellyfin.Api/Controllers/SubtitleController.cs

@@ -11,6 +11,9 @@ using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.SubtitleDtos;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.MediaEncoding;
@@ -21,6 +24,7 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Providers;
 using MediaBrowser.Model.Providers;
+using MediaBrowser.Model.Subtitles;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc;
@@ -34,6 +38,7 @@ namespace Jellyfin.Api.Controllers
     [Route("")]
     [Route("")]
     public class SubtitleController : BaseJellyfinApiController
     public class SubtitleController : BaseJellyfinApiController
     {
     {
+        private readonly IServerConfigurationManager _serverConfigurationManager;
         private readonly ILibraryManager _libraryManager;
         private readonly ILibraryManager _libraryManager;
         private readonly ISubtitleManager _subtitleManager;
         private readonly ISubtitleManager _subtitleManager;
         private readonly ISubtitleEncoder _subtitleEncoder;
         private readonly ISubtitleEncoder _subtitleEncoder;
@@ -46,6 +51,7 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="SubtitleController"/> class.
         /// Initializes a new instance of the <see cref="SubtitleController"/> class.
         /// </summary>
         /// </summary>
+        /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
         /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
         /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
         /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param>
         /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param>
         /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param>
         /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param>
@@ -55,6 +61,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
         /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
         /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
         /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
         public SubtitleController(
         public SubtitleController(
+            IServerConfigurationManager serverConfigurationManager,
             ILibraryManager libraryManager,
             ILibraryManager libraryManager,
             ISubtitleManager subtitleManager,
             ISubtitleManager subtitleManager,
             ISubtitleEncoder subtitleEncoder,
             ISubtitleEncoder subtitleEncoder,
@@ -64,6 +71,7 @@ namespace Jellyfin.Api.Controllers
             IAuthorizationContext authContext,
             IAuthorizationContext authContext,
             ILogger<SubtitleController> logger)
             ILogger<SubtitleController> logger)
         {
         {
+            _serverConfigurationManager = serverConfigurationManager;
             _libraryManager = libraryManager;
             _libraryManager = libraryManager;
             _subtitleManager = subtitleManager;
             _subtitleManager = subtitleManager;
             _subtitleEncoder = subtitleEncoder;
             _subtitleEncoder = subtitleEncoder;
@@ -319,6 +327,33 @@ namespace Jellyfin.Api.Controllers
             return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
             return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
         }
         }
 
 
+        /// <summary>
+        /// Upload an external subtitle file.
+        /// </summary>
+        /// <param name="itemId">The item the subtitle belongs to.</param>
+        /// <param name="body">The request body.</param>
+        /// <response code="204">Subtitle uploaded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("Videos/{itemId}/Subtitles")]
+        public async Task<ActionResult> UploadSubtitle(
+            [FromRoute, Required] Guid itemId,
+            [FromBody, Required] UploadSubtitleDto body)
+        {
+            var video = (Video)_libraryManager.GetItemById(itemId);
+            var data = Convert.FromBase64String(body.Data);
+            await using var memoryStream = new MemoryStream(data);
+            await _subtitleManager.UploadSubtitle(
+                video,
+                new SubtitleResponse
+                {
+                    Format = body.Format,
+                    Language = body.Language,
+                    IsForced = body.IsForced,
+                    Stream = memoryStream
+                }).ConfigureAwait(false);
+            return NoContent();
+        }
+
         /// <summary>
         /// <summary>
         /// Encodes a subtitle in the specified format.
         /// Encodes a subtitle in the specified format.
         /// </summary>
         /// </summary>
@@ -351,5 +386,95 @@ namespace Jellyfin.Api.Controllers
                 copyTimestamps,
                 copyTimestamps,
                 CancellationToken.None);
                 CancellationToken.None);
         }
         }
+
+        /// <summary>
+        /// Gets a list of available fallback font files.
+        /// </summary>
+        /// <response code="200">Information retrieved.</response>
+        /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns>
+        [HttpGet("FallbackFont/Fonts")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public IEnumerable<FontFile> GetFallbackFontList()
+        {
+            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+            var fallbackFontPath = encodingOptions.FallbackFontPath;
+
+            if (!string.IsNullOrEmpty(fallbackFontPath))
+            {
+                var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false);
+                var fontFiles = files
+                    .Select(i => new FontFile
+                    {
+                        Name = i.Name,
+                        Size = i.Length,
+                        DateCreated = _fileSystem.GetCreationTimeUtc(i),
+                        DateModified = _fileSystem.GetLastWriteTimeUtc(i)
+                    })
+                    .OrderBy(i => i.Size)
+                    .ThenBy(i => i.Name)
+                    .ThenByDescending(i => i.DateModified)
+                    .ThenByDescending(i => i.DateCreated);
+                // max total size 20M
+                const int MaxSize = 20971520;
+                var sizeCounter = 0L;
+                foreach (var fontFile in fontFiles)
+                {
+                    sizeCounter += fontFile.Size;
+                    if (sizeCounter >= MaxSize)
+                    {
+                        _logger.LogWarning("Some fonts will not be sent due to size limitations");
+                        yield break;
+                    }
+
+                    yield return fontFile;
+                }
+            }
+            else
+            {
+                _logger.LogWarning("The path of fallback font folder has not been set");
+                encodingOptions.EnableFallbackFont = false;
+            }
+        }
+
+        /// <summary>
+        /// Gets a fallback font file.
+        /// </summary>
+        /// <param name="name">The name of the fallback font file to get.</param>
+        /// <response code="200">Fallback font file retrieved.</response>
+        /// <returns>The fallback font file.</returns>
+        [HttpGet("FallbackFont/Fonts/{name}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult GetFallbackFont([FromRoute, Required] string name)
+        {
+            var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
+            var fallbackFontPath = encodingOptions.FallbackFontPath;
+
+            if (!string.IsNullOrEmpty(fallbackFontPath))
+            {
+                var fontFile = _fileSystem.GetFiles(fallbackFontPath)
+                    .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
+                var fileSize = fontFile?.Length;
+
+                if (fontFile != null && fileSize != null && fileSize > 0)
+                {
+                    _logger.LogDebug("Fallback font size is {fileSize} Bytes", fileSize);
+                    return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName));
+                }
+                else
+                {
+                    _logger.LogWarning("The selected font is null or empty");
+                }
+            }
+            else
+            {
+                _logger.LogWarning("The path of fallback font folder has not been set");
+                encodingOptions.EnableFallbackFont = false;
+            }
+
+            // returning HTTP 204 will break the SubtitlesOctopus
+            return Ok();
+        }
     }
     }
 }
 }

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

@@ -146,12 +146,12 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? searchTerm,
             [FromQuery] string? searchTerm,
             [FromQuery] string? sortOrder,
             [FromQuery] string? sortOrder,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] bool? isFavorite,
             [FromQuery] string? mediaTypes,
             [FromQuery] string? mediaTypes,
-            [FromQuery] ImageType[] imageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
             [FromQuery] string? sortBy,
             [FromQuery] string? sortBy,
             [FromQuery] bool? isPlayed,
             [FromQuery] bool? isPlayed,
             [FromQuery] string? genres,
             [FromQuery] string? genres,
@@ -160,7 +160,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,
             [FromQuery] string? personTypes,

+ 15 - 18
Jellyfin.Api/Controllers/TvShowsController.cs

@@ -5,6 +5,7 @@ using System.Globalization;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Enums;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
@@ -58,7 +59,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="userId">The user id of the user to get the next up episodes for.</param>
         /// <param name="userId">The user id of the user to get the next up episodes for.</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="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="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="seriesId">Optional. Filter by series id.</param>
         /// <param name="seriesId">Optional. Filter by series 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="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
         /// <param name="enableImges">Optional. Include image information in output.</param>
         /// <param name="enableImges">Optional. Include image information in output.</param>
@@ -73,17 +74,16 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? seriesId,
             [FromQuery] string? seriesId,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
             [FromQuery] bool? enableImges,
             [FromQuery] bool? enableImges,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool enableTotalRecordCount = true)
             [FromQuery] bool enableTotalRecordCount = true)
         {
         {
-            var options = new DtoOptions()
-                .AddItemFields(fields!)
+            var options = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!);
                 .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!);
 
 
@@ -118,7 +118,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="userId">The user id of the user to get the upcoming episodes for.</param>
         /// <param name="userId">The user id of the user to get the upcoming episodes for.</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="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="limit">Optional. The maximum number of records to return.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</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="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
         /// <param name="enableImges">Optional. Include image information in output.</param>
         /// <param name="enableImges">Optional. Include image information in output.</param>
         /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
         /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
@@ -131,11 +131,11 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
             [FromQuery] bool? enableImges,
             [FromQuery] bool? enableImges,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData)
             [FromQuery] bool? enableUserData)
         {
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -146,8 +146,7 @@ namespace Jellyfin.Api.Controllers
 
 
             var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
             var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
 
 
-            var options = new DtoOptions()
-                .AddItemFields(fields!)
+            var options = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!);
                 .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!);
 
 
@@ -197,7 +196,7 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
         public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
             [FromRoute, Required] string seriesId,
             [FromRoute, Required] string seriesId,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] int? season,
             [FromQuery] int? season,
             [FromQuery] string? seasonId,
             [FromQuery] string? seasonId,
             [FromQuery] bool? isMissing,
             [FromQuery] bool? isMissing,
@@ -207,7 +206,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] string? sortBy)
             [FromQuery] string? sortBy)
         {
         {
@@ -217,8 +216,7 @@ namespace Jellyfin.Api.Controllers
 
 
             List<BaseItem> episodes;
             List<BaseItem> episodes;
 
 
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields!)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
 
 
@@ -320,13 +318,13 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
         public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
             [FromRoute, Required] string seriesId,
             [FromRoute, Required] string seriesId,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] bool? isSpecialSeason,
             [FromQuery] bool? isSpecialSeason,
             [FromQuery] bool? isMissing,
             [FromQuery] bool? isMissing,
             [FromQuery] string? adjacentTo,
             [FromQuery] string? adjacentTo,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData)
             [FromQuery] bool? enableUserData)
         {
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -345,8 +343,7 @@ namespace Jellyfin.Api.Controllers
                 AdjacentTo = adjacentTo
                 AdjacentTo = adjacentTo
             });
             });
 
 
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
 
 

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

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
@@ -251,7 +252,7 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// </summary>
         /// <param name="userId">User id.</param>
         /// <param name="userId">User id.</param>
         /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
         /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, SortName, Studios, Taglines.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
         /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
         /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
         /// <param name="isPlayed">Filter by items that are played, or not.</param>
         /// <param name="isPlayed">Filter by items that are played, or not.</param>
         /// <param name="enableImages">Optional. include image information in output.</param>
         /// <param name="enableImages">Optional. include image information in output.</param>
@@ -267,12 +268,12 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia(
         public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] Guid userId,
             [FromQuery] Guid? parentId,
             [FromQuery] Guid? parentId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] bool? isPlayed,
             [FromQuery] bool? isPlayed,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int limit = 20,
             [FromQuery] int limit = 20,
             [FromQuery] bool groupItems = true)
             [FromQuery] bool groupItems = true)
@@ -287,8 +288,7 @@ namespace Jellyfin.Api.Controllers
                 }
                 }
             }
             }
 
 
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 

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

@@ -46,7 +46,7 @@ namespace Jellyfin.Api.Controllers
         [Produces(MediaTypeNames.Application.Octet)]
         [Produces(MediaTypeNames.Application.Octet)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public async Task<ActionResult<FileStreamResult>> GetAttachment(
+        public async Task<ActionResult> GetAttachment(
             [FromRoute, Required] Guid videoId,
             [FromRoute, Required] Guid videoId,
             [FromRoute, Required] string mediaSourceId,
             [FromRoute, Required] string mediaSourceId,
             [FromRoute, Required] int index)
             [FromRoute, Required] int index)

+ 3 - 1
Jellyfin.Api/Controllers/VideoHlsController.cs

@@ -12,6 +12,7 @@ using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Models.PlaybackDtos;
 using Jellyfin.Api.Models.PlaybackDtos;
 using Jellyfin.Api.Models.StreamingDtos;
 using Jellyfin.Api.Models.StreamingDtos;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Dlna;
@@ -363,8 +364,9 @@ namespace Jellyfin.Api.Controllers
             var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
             var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
             var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
             var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
 
 
+            var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
             var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
             var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
-            var outputPrefix = Path.Combine(Path.GetDirectoryName(outputPath), outputFileNameWithoutExtension);
+            var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
             var outputExtension = HlsHelpers.GetSegmentFileExtension(state.Request.SegmentContainer);
             var outputExtension = HlsHelpers.GetSegmentFileExtension(state.Request.SegmentContainer);
             var outputTsArg = outputPrefix + "%d" + outputExtension;
             var outputTsArg = outputPrefix + "%d" + outputExtension;
 
 

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

@@ -5,6 +5,7 @@ using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities;
@@ -51,7 +52,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="limit">Optional. The maximum number of records to return.</param>
         /// <param name="limit">Optional. The maximum number of records to return.</param>
         /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
         /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
         /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
         /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
-        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</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 excluded based on item type. This allows multiple, comma delimited.</param>
         /// <param name="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param>
         /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param>
         /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param>
         /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
         /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
@@ -71,20 +72,19 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] string? sortOrder,
             [FromQuery] string? sortOrder,
             [FromQuery] string? parentId,
             [FromQuery] string? parentId,
-            [FromQuery] string? fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] string? mediaTypes,
             [FromQuery] string? mediaTypes,
             [FromQuery] string? sortBy,
             [FromQuery] string? sortBy,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] ImageType[] enableImageTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
             [FromQuery] Guid? userId,
             [FromQuery] Guid? userId,
             [FromQuery] bool recursive = true,
             [FromQuery] bool recursive = true,
             [FromQuery] bool? enableImages = true)
             [FromQuery] bool? enableImages = true)
         {
         {
-            var dtoOptions = new DtoOptions()
-                .AddItemFields(fields)
+            var dtoOptions = new DtoOptions { Fields = fields }
                 .AddClientFields(Request)
                 .AddClientFields(Request)
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
                 .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
 

+ 6 - 40
Jellyfin.Api/Extensions/DtoExtensions.cs

@@ -1,6 +1,8 @@
 using System;
 using System;
+using System.Collections.Generic;
 using System.Linq;
 using System.Linq;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.Helpers;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Querying;
@@ -13,42 +15,6 @@ namespace Jellyfin.Api.Extensions
     /// </summary>
     /// </summary>
     public static class DtoExtensions
     public static class DtoExtensions
     {
     {
-        /// <summary>
-        /// Add Dto Item fields.
-        /// </summary>
-        /// <remarks>
-        /// Converted from IHasItemFields.
-        /// Legacy order: 1.
-        /// </remarks>
-        /// <param name="dtoOptions">DtoOptions object.</param>
-        /// <param name="fields">Comma delimited string of fields.</param>
-        /// <returns>Modified DtoOptions object.</returns>
-        internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string? fields)
-        {
-            if (string.IsNullOrEmpty(fields))
-            {
-                dtoOptions.Fields = Array.Empty<ItemFields>();
-            }
-            else
-            {
-                dtoOptions.Fields = fields.Split(',')
-                    .Select(v =>
-                    {
-                        if (Enum.TryParse(v, true, out ItemFields value))
-                        {
-                            return (ItemFields?)value;
-                        }
-
-                        return null;
-                    })
-                    .Where(i => i.HasValue)
-                    .Select(i => i!.Value)
-                    .ToArray();
-            }
-
-            return dtoOptions;
-        }
-
         /// <summary>
         /// <summary>
         /// Add additional fields depending on client.
         /// Add additional fields depending on client.
         /// </summary>
         /// </summary>
@@ -79,7 +45,7 @@ namespace Jellyfin.Api.Extensions
                     client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 ||
                     client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 ||
                     client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1)
                     client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1)
                 {
                 {
-                    int oldLen = dtoOptions.Fields.Length;
+                    int oldLen = dtoOptions.Fields.Count;
                     var arr = new ItemFields[oldLen + 1];
                     var arr = new ItemFields[oldLen + 1];
                     dtoOptions.Fields.CopyTo(arr, 0);
                     dtoOptions.Fields.CopyTo(arr, 0);
                     arr[oldLen] = ItemFields.RecursiveItemCount;
                     arr[oldLen] = ItemFields.RecursiveItemCount;
@@ -97,7 +63,7 @@ namespace Jellyfin.Api.Extensions
                     client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 ||
                     client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 ||
                     client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1)
                     client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1)
                 {
                 {
-                    int oldLen = dtoOptions.Fields.Length;
+                    int oldLen = dtoOptions.Fields.Count;
                     var arr = new ItemFields[oldLen + 1];
                     var arr = new ItemFields[oldLen + 1];
                     dtoOptions.Fields.CopyTo(arr, 0);
                     dtoOptions.Fields.CopyTo(arr, 0);
                     arr[oldLen] = ItemFields.ChildCount;
                     arr[oldLen] = ItemFields.ChildCount;
@@ -126,7 +92,7 @@ namespace Jellyfin.Api.Extensions
             bool? enableImages,
             bool? enableImages,
             bool? enableUserData,
             bool? enableUserData,
             int? imageTypeLimit,
             int? imageTypeLimit,
-            ImageType[] enableImageTypes)
+            IReadOnlyList<ImageType> enableImageTypes)
         {
         {
             dtoOptions.EnableImages = enableImages ?? true;
             dtoOptions.EnableImages = enableImages ?? true;
 
 
@@ -140,7 +106,7 @@ namespace Jellyfin.Api.Extensions
                 dtoOptions.EnableUserData = enableUserData.Value;
                 dtoOptions.EnableUserData = enableUserData.Value;
             }
             }
 
 
-            if (enableImageTypes.Length != 0)
+            if (enableImageTypes.Count != 0)
             {
             {
                 dtoOptions.ImageTypes = enableImageTypes;
                 dtoOptions.ImageTypes = enableImageTypes;
             }
             }

+ 8 - 1
Jellyfin.Api/Helpers/AudioHelper.cs

@@ -1,8 +1,10 @@
-using System.Net.Http;
+using System;
+using System.Net.Http;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Api.Models.StreamingDtos;
 using Jellyfin.Api.Models.StreamingDtos;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Devices;
@@ -98,6 +100,11 @@ namespace Jellyfin.Api.Helpers
             TranscodingJobType transcodingJobType,
             TranscodingJobType transcodingJobType,
             StreamingRequestDto streamingRequest)
             StreamingRequestDto streamingRequest)
         {
         {
+            if (_httpContextAccessor.HttpContext == null)
+            {
+                throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext));
+            }
+
             bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head;
             bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head;
             var cancellationTokenSource = new CancellationTokenSource();
             var cancellationTokenSource = new CancellationTokenSource();
 
 

+ 13 - 0
Jellyfin.Api/Helpers/ClaimHelpers.cs

@@ -63,6 +63,19 @@ namespace Jellyfin.Api.Helpers
         public static string? GetToken(in ClaimsPrincipal user)
         public static string? GetToken(in ClaimsPrincipal user)
             => GetClaimValue(user, InternalClaimTypes.Token);
             => GetClaimValue(user, InternalClaimTypes.Token);
 
 
+        /// <summary>
+        /// Gets a flag specifying whether the request is using an api key.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>The flag specifying whether the request is using an api key.</returns>
+        public static bool GetIsApiKey(in ClaimsPrincipal user)
+        {
+            var claimValue = GetClaimValue(user, InternalClaimTypes.IsApiKey);
+            return !string.IsNullOrEmpty(claimValue)
+                   && bool.TryParse(claimValue, out var parsedClaimValue)
+                   && parsedClaimValue;
+        }
+
         private static string? GetClaimValue(in ClaimsPrincipal user, string name)
         private static string? GetClaimValue(in ClaimsPrincipal user, string name)
         {
         {
             return user?.Identities
             return user?.Identities

+ 6 - 1
Jellyfin.Api/Helpers/DynamicHlsHelper.cs

@@ -113,7 +113,7 @@ namespace Jellyfin.Api.Helpers
             StreamingRequestDto streamingRequest,
             StreamingRequestDto streamingRequest,
             bool enableAdaptiveBitrateStreaming)
             bool enableAdaptiveBitrateStreaming)
         {
         {
-            var isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == WebRequestMethods.Http.Head;
+            var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head;
             var cancellationTokenSource = new CancellationTokenSource();
             var cancellationTokenSource = new CancellationTokenSource();
             return await GetMasterPlaylistInternal(
             return await GetMasterPlaylistInternal(
                 streamingRequest,
                 streamingRequest,
@@ -130,6 +130,11 @@ namespace Jellyfin.Api.Helpers
             TranscodingJobType transcodingJobType,
             TranscodingJobType transcodingJobType,
             CancellationTokenSource cancellationTokenSource)
             CancellationTokenSource cancellationTokenSource)
         {
         {
+            if (_httpContextAccessor.HttpContext == null)
+            {
+                throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext));
+            }
+
             using var state = await StreamingHelpers.GetStreamingState(
             using var state = await StreamingHelpers.GetStreamingState(
                     streamingRequest,
                     streamingRequest,
                     _httpContextAccessor.HttpContext.Request,
                     _httpContextAccessor.HttpContext.Request,

+ 2 - 2
Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs

@@ -37,8 +37,8 @@ namespace Jellyfin.Api.Helpers
             }
             }
 
 
             // Can't dispose the response as it's required up the call chain.
             // Can't dispose the response as it's required up the call chain.
-            var response = await httpClient.GetAsync(state.MediaPath).ConfigureAwait(false);
-            var contentType = response.Content.Headers.ContentType.ToString();
+            var response = await httpClient.GetAsync(new Uri(state.MediaPath)).ConfigureAwait(false);
+            var contentType = response.Content.Headers.ContentType?.ToString();
 
 
             httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
             httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
 
 

+ 3 - 3
Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs

@@ -48,7 +48,7 @@ namespace Jellyfin.Api.Helpers
         /// </summary>
         /// </summary>
         /// <param name="profile">AAC profile.</param>
         /// <param name="profile">AAC profile.</param>
         /// <returns>AAC codec string.</returns>
         /// <returns>AAC codec string.</returns>
-        public static string GetAACString(string profile)
+        public static string GetAACString(string? profile)
         {
         {
             StringBuilder result = new StringBuilder("mp4a", 9);
             StringBuilder result = new StringBuilder("mp4a", 9);
 
 
@@ -107,7 +107,7 @@ namespace Jellyfin.Api.Helpers
         /// <param name="profile">H.264 profile.</param>
         /// <param name="profile">H.264 profile.</param>
         /// <param name="level">H.264 level.</param>
         /// <param name="level">H.264 level.</param>
         /// <returns>H.264 string.</returns>
         /// <returns>H.264 string.</returns>
-        public static string GetH264String(string profile, int level)
+        public static string GetH264String(string? profile, int level)
         {
         {
             StringBuilder result = new StringBuilder("avc1", 11);
             StringBuilder result = new StringBuilder("avc1", 11);
 
 
@@ -141,7 +141,7 @@ namespace Jellyfin.Api.Helpers
         /// <param name="profile">H.265 profile.</param>
         /// <param name="profile">H.265 profile.</param>
         /// <param name="level">H.265 level.</param>
         /// <param name="level">H.265 level.</param>
         /// <returns>H.265 string.</returns>
         /// <returns>H.265 string.</returns>
-        public static string GetH265String(string profile, int level)
+        public static string GetH265String(string? profile, int level)
         {
         {
             // The h265 syntax is a bit of a mystery at the time this comment was written.
             // The h265 syntax is a bit of a mystery at the time this comment was written.
             // This is what I've found through various sources:
             // This is what I've found through various sources:

+ 5 - 0
Jellyfin.Api/Helpers/HlsHelpers.cs

@@ -47,6 +47,11 @@ namespace Jellyfin.Api.Helpers
                         while (!reader.EndOfStream)
                         while (!reader.EndOfStream)
                         {
                         {
                             var line = await reader.ReadLineAsync().ConfigureAwait(false);
                             var line = await reader.ReadLineAsync().ConfigureAwait(false);
+                            if (line == null)
+                            {
+                                // Nothing currently in buffer.
+                                break;
+                            }
 
 
                             if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1)
                             if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1)
                             {
                             {

+ 6 - 0
Jellyfin.Api/Helpers/ProgressiveFileCopier.cs

@@ -5,6 +5,7 @@ using System.Runtime.InteropServices;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using Jellyfin.Api.Models.PlaybackDtos;
 using Jellyfin.Api.Models.PlaybackDtos;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.IO;
 
 
@@ -90,6 +91,11 @@ namespace Jellyfin.Api.Helpers
                     allowAsyncFileRead = true;
                     allowAsyncFileRead = true;
                 }
                 }
 
 
+                if (_path == null)
+                {
+                    throw new ResourceNotFoundException(nameof(_path));
+                }
+
                 await using var inputStream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions);
                 await using var inputStream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions);
 
 
                 var eofCount = 0;
                 var eofCount = 0;

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