Explorar el Código

Merge pull request #86 from jellyfin/master

Updating from master
BaronGreenback hace 4 años
padre
commit
c54bdb4a0a
Se han modificado 39 ficheros con 402 adiciones y 160 borrados
  1. 6 0
      .ci/azure-pipelines-api-client.yml
  2. 3 0
      .npmrc
  3. 20 20
      Emby.Server.Implementations/Localization/Core/fi.json
  4. 3 0
      Emby.Server.Implementations/Localization/Core/hi.json
  5. 1 1
      Emby.Server.Implementations/Localization/Core/sq.json
  6. 1 1
      Emby.Server.Implementations/Localization/Core/sv.json
  7. 1 1
      Emby.Server.Implementations/Localization/Core/ta.json
  8. 3 2
      Jellyfin.Api/Controllers/ArtistsController.cs
  9. 2 1
      Jellyfin.Api/Controllers/GenresController.cs
  10. 3 3
      Jellyfin.Api/Controllers/ImageController.cs
  11. 8 7
      Jellyfin.Api/Controllers/InstantMixController.cs
  12. 4 4
      Jellyfin.Api/Controllers/ItemsController.cs
  13. 6 5
      Jellyfin.Api/Controllers/LiveTvController.cs
  14. 2 1
      Jellyfin.Api/Controllers/MusicGenresController.cs
  15. 2 1
      Jellyfin.Api/Controllers/PersonsController.cs
  16. 2 1
      Jellyfin.Api/Controllers/PlaylistsController.cs
  17. 2 2
      Jellyfin.Api/Controllers/ScheduledTasksController.cs
  18. 2 1
      Jellyfin.Api/Controllers/StudiosController.cs
  19. 2 2
      Jellyfin.Api/Controllers/TrailersController.cs
  20. 5 4
      Jellyfin.Api/Controllers/TvShowsController.cs
  21. 7 11
      Jellyfin.Api/Controllers/UserController.cs
  22. 1 1
      Jellyfin.Api/Controllers/UserLibraryController.cs
  23. 1 1
      Jellyfin.Api/Controllers/VideoHlsController.cs
  24. 2 1
      Jellyfin.Api/Controllers/YearsController.cs
  25. 3 5
      Jellyfin.Api/Extensions/DtoExtensions.cs
  26. 1 1
      Jellyfin.Api/Helpers/DynamicHlsHelper.cs
  27. 28 0
      Jellyfin.Api/Helpers/RequestHelpers.cs
  28. 7 1
      Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
  29. 44 74
      Jellyfin.Server.Implementations/Users/UserManager.cs
  30. 1 1
      Jellyfin.Server/Jellyfin.Server.csproj
  31. 53 0
      MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs
  32. 28 0
      MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs
  33. 6 3
      MediaBrowser.Controller/Library/IUserManager.cs
  34. 18 2
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  35. 10 0
      MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
  36. 1 1
      tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
  37. 92 0
      tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs
  38. 20 0
      tests/Jellyfin.Common.Tests/Models/GenericBodyModel.cs
  39. 1 1
      tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj

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

@@ -28,6 +28,12 @@ jobs:
       inputs:
         script: "wget https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/${{ parameters.GeneratorVersion }}/openapi-generator-cli-${{ parameters.GeneratorVersion }}.jar -O openapi-generator-cli.jar"
 
+## Authenticate with npm registry
+    - task: npmAuthenticate@0
+      inputs:
+        workingFile: ./.npmrc
+        customEndpoint: 'jellyfin-bot for NPM'
+
 ## Generate npm api client
 # Unstable
     - task: CmdLine@2

+ 3 - 0
.npmrc

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

+ 20 - 20
Emby.Server.Implementations/Localization/Core/fi.json

@@ -1,7 +1,7 @@
 {
     "HeaderLiveTV": "Live-TV",
     "NewVersionIsAvailable": "Uusi versio Jellyfin palvelimesta on ladattavissa.",
-    "NameSeasonUnknown": "Tuntematon Kausi",
+    "NameSeasonUnknown": "Tuntematon kausi",
     "NameSeasonNumber": "Kausi {0}",
     "NameInstallFailed": "{0} asennus epäonnistui",
     "MusicVideos": "Musiikkivideot",
@@ -19,23 +19,23 @@
     "ItemAddedWithName": "{0} lisättiin kirjastoon",
     "Inherit": "Periytyä",
     "HomeVideos": "Kotivideot",
-    "HeaderRecordingGroups": "Nauhoiteryhmät",
+    "HeaderRecordingGroups": "Tallennusryhmät",
     "HeaderNextUp": "Seuraavaksi",
-    "HeaderFavoriteSongs": "Lempikappaleet",
-    "HeaderFavoriteShows": "Lempisarjat",
-    "HeaderFavoriteEpisodes": "Lempijaksot",
-    "HeaderFavoriteArtists": "Lempiartistit",
-    "HeaderFavoriteAlbums": "Lempialbumit",
+    "HeaderFavoriteSongs": "Suosikkikappaleet",
+    "HeaderFavoriteShows": "Suosikkisarjat",
+    "HeaderFavoriteEpisodes": "Suosikkijaksot",
+    "HeaderFavoriteArtists": "Suosikkiartistit",
+    "HeaderFavoriteAlbums": "Suosikkialbumit",
     "HeaderContinueWatching": "Jatka katsomista",
-    "HeaderAlbumArtists": "Albumin esittäjä",
+    "HeaderAlbumArtists": "Albumin artistit",
     "Genres": "Tyylilajit",
     "Folders": "Kansiot",
     "Favorites": "Suosikit",
     "FailedLoginAttemptWithUserName": "Kirjautuminen epäonnistui kohteesta {0}",
     "DeviceOnlineWithName": "{0} on yhdistetty",
-    "DeviceOfflineWithName": "{0} on katkaissut yhteytensä",
+    "DeviceOfflineWithName": "{0} yhteys on katkaistu",
     "Collections": "Kokoelmat",
-    "ChapterNameValue": "Luku: {0}",
+    "ChapterNameValue": "Jakso: {0}",
     "Channels": "Kanavat",
     "CameraImageUploadedFrom": "Uusi kamerakuva on ladattu {0}",
     "Books": "Kirjat",
@@ -61,25 +61,25 @@
     "UserPolicyUpdatedWithName": "Käyttöoikeudet päivitetty käyttäjälle {0}",
     "UserPasswordChangedWithName": "Salasana vaihdettu käyttäjälle {0}",
     "UserOnlineFromDevice": "{0} on paikalla osoitteesta {1}",
-    "UserOfflineFromDevice": "{0} yhteys katkaistu {1}",
+    "UserOfflineFromDevice": "{0} yhteys katkaistu kohteesta {1}",
     "UserLockedOutWithName": "Käyttäjä {0} lukittu",
     "UserDownloadingItemWithValues": "{0} lataa {1}",
     "UserDeletedWithName": "Käyttäjä {0} poistettu",
     "UserCreatedWithName": "Käyttäjä {0} luotu",
-    "TvShows": "TV-sarjat",
+    "TvShows": "TV-ohjelmat",
     "Sync": "Synkronoi",
-    "SubtitleDownloadFailureFromForItem": "Tekstitysten lataus ({0} -> {1}) epäonnistui //this string would have to be generated for each provider and movie because of finnish cases, sorry",
-    "StartupEmbyServerIsLoading": "Jellyfin palvelin latautuu. Kokeile hetken kuluttua uudelleen.",
+    "SubtitleDownloadFailureFromForItem": "Tekstitystä ei voitu ladata osoitteesta {0} kohteelle {1}",
+    "StartupEmbyServerIsLoading": "Jellyfin palvelin latautuu. Yritä hetken kuluttua uudelleen.",
     "Songs": "Kappaleet",
-    "Shows": "Sarjat",
-    "ServerNameNeedsToBeRestarted": "{0} täytyy käynnistää uudelleen",
+    "Shows": "Ohjelmat",
+    "ServerNameNeedsToBeRestarted": "{0} on käynnistettävä uudelleen",
     "ProviderValue": "Tarjoaja: {0}",
     "Plugin": "Liitännäinen",
     "NotificationOptionVideoPlaybackStopped": "Videon toisto pysäytetty",
     "NotificationOptionVideoPlayback": "Videota toistetaan",
     "NotificationOptionUserLockedOut": "Käyttäjä kirjautui ulos",
     "NotificationOptionTaskFailed": "Ajastettu tehtävä epäonnistui",
-    "NotificationOptionServerRestartRequired": "Palvelin pitää käynnistää uudelleen",
+    "NotificationOptionServerRestartRequired": "Palvelin on käynnistettävä uudelleen",
     "NotificationOptionPluginUpdateInstalled": "Liitännäinen päivitetty",
     "NotificationOptionPluginUninstalled": "Liitännäinen poistettu",
     "NotificationOptionPluginInstalled": "Liitännäinen asennettu",
@@ -104,10 +104,10 @@
     "TaskRefreshPeople": "Päivitä henkilöt",
     "TaskCleanLogsDescription": "Poistaa lokitiedostot jotka ovat yli {0} päivää vanhoja.",
     "TaskCleanLogs": "Puhdista lokihakemisto",
-    "TaskRefreshLibraryDescription": "Skannaa mediakirjastosi uusien tiedostojen varalle, sekä virkistää metatiedot.",
+    "TaskRefreshLibraryDescription": "Skannaa mediakirjastosi uudet tiedostot ja päivittää metatiedot.",
     "TaskRefreshLibrary": "Skannaa mediakirjasto",
-    "TaskRefreshChapterImagesDescription": "Luo pienoiskuvat videoille joissa on lukuja.",
-    "TaskRefreshChapterImages": "Eristä lukujen kuvat",
+    "TaskRefreshChapterImagesDescription": "Luo pienoiskuvat videoille joissa on jaksoja.",
+    "TaskRefreshChapterImages": "Pura jakson kuvat",
     "TaskCleanCacheDescription": "Poistaa järjestelmälle tarpeettomat väliaikaistiedostot.",
     "TaskCleanCache": "Tyhjennä välimuisti-hakemisto",
     "TasksChannelsCategory": "Internet kanavat",

+ 3 - 0
Emby.Server.Implementations/Localization/Core/hi.json

@@ -0,0 +1,3 @@
+{
+    "Albums": "आल्बुम्"
+}

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

@@ -112,5 +112,5 @@
     "Artists": "Artistë",
     "Application": "Aplikacioni",
     "AppDeviceValues": "Aplikacioni: {0}, Pajisja: {1}",
-    "Albums": "Albumet"
+    "Albums": "Albume"
 }

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

@@ -9,7 +9,7 @@
     "Channels": "Kanaler",
     "ChapterNameValue": "Kapitel {0}",
     "Collections": "Samlingar",
-    "DeviceOfflineWithName": "{0} har kopplat från",
+    "DeviceOfflineWithName": "{0} har kopplat ner",
     "DeviceOnlineWithName": "{0} är ansluten",
     "FailedLoginAttemptWithUserName": "Misslyckat inloggningsförsök från {0}",
     "Favorites": "Favoriter",

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

@@ -101,7 +101,7 @@
     "UserOfflineFromDevice": "{0} இலிருந்து {1} துண்டிக்கப்பட்டுள்ளது",
     "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இலிருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன",
     "TaskDownloadMissingSubtitlesDescription": "மீத்தரவு உள்ளமைவின் அடிப்படையில் வசன வரிகள் காணாமல் போனதற்கு இணையத்தைத் தேடுகிறது.",
-    "TaskCleanTranscodeDescription": "டிரான்ஸ்கோட் கோப்புகளை ஒரு நாளுக்கு மேல் பழையதாக நீக்குகிறது.",
+    "TaskCleanTranscodeDescription": "ஒரு நாளைக்கு மேற்பட்ட பழைய டிரான்ஸ்கோட் கோப்புகளை நீக்குகிறது.",
     "TaskUpdatePluginsDescription": "தானாகவே புதுப்பிக்க கட்டமைக்கப்பட்ட உட்செருகிகளுக்கான புதுப்பிப்புகளை பதிவிறக்குகிறது மற்றும் நிறுவுகிறது.",
     "TaskRefreshPeopleDescription": "உங்கள் ஊடக நூலகத்தில் உள்ள நடிகர்கள் மற்றும் இயக்குனர்களுக்கான மீத்தரவை புதுப்பிக்கும்.",
     "TaskCleanLogsDescription": "{0} நாட்களுக்கு மேல் இருக்கும் பதிவு கோப்புகளை நீக்கும்.",

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

@@ -9,6 +9,7 @@ using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -99,7 +100,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,
@@ -308,7 +309,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,

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

@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -100,7 +101,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,

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

@@ -109,7 +109,7 @@ namespace Jellyfin.Api.Controllers
             var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
             if (user.ProfileImage != null)
             {
-                _userManager.ClearProfileImage(user);
+                await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
             }
 
             user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType)));
@@ -138,7 +138,7 @@ namespace Jellyfin.Api.Controllers
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        public ActionResult DeleteUserImage(
+        public async Task<ActionResult> DeleteUserImage(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] ImageType imageType,
             [FromRoute] int? index = null)
@@ -158,7 +158,7 @@ namespace Jellyfin.Api.Controllers
                 _logger.LogError(e, "Error deleting user profile image:");
             }
 
-            _userManager.ClearProfileImage(user);
+            await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
             return NoContent();
         }
 

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

@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -71,7 +72,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes)
+            [FromQuery] ImageType[] enableImageTypes)
         {
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -108,7 +109,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes)
+            [FromQuery] ImageType[] enableImageTypes)
         {
             var album = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -145,7 +146,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes)
+            [FromQuery] ImageType[] enableImageTypes)
         {
             var playlist = (Playlist)_libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -182,7 +183,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes)
+            [FromQuery] ImageType[] enableImageTypes)
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
@@ -218,7 +219,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes)
+            [FromQuery] ImageType[] enableImageTypes)
         {
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -255,7 +256,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes)
+            [FromQuery] ImageType[] enableImageTypes)
         {
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -292,7 +293,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes)
+            [FromQuery] ImageType[] enableImageTypes)
         {
             var item = _libraryManager.GetItemById(id);
             var user = userId.HasValue && !userId.Equals(Guid.Empty)

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

@@ -185,7 +185,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] string? mediaTypes,
-            [FromQuery] string? imageTypes,
+            [FromQuery] ImageType[] imageTypes,
             [FromQuery] string? sortBy,
             [FromQuery] bool? isPlayed,
             [FromQuery] string? genres,
@@ -194,7 +194,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,
@@ -342,7 +342,7 @@ namespace Jellyfin.Api.Controllers
                     PersonIds = RequestHelpers.GetGuids(personIds),
                     PersonTypes = RequestHelpers.Split(personTypes, ',', true),
                     Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
-                    ImageTypes = RequestHelpers.Split(imageTypes, ',', true).Select(v => Enum.Parse<ImageType>(v, true)).ToArray(),
+                    ImageTypes = imageTypes,
                     VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(),
                     AdjacentTo = adjacentTo,
                     ItemIds = RequestHelpers.GetGuids(ids),
@@ -536,7 +536,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? mediaTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? excludeItemTypes,
             [FromQuery] string? includeItemTypes,
             [FromQuery] bool enableTotalRecordCount = true,

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

@@ -26,6 +26,7 @@ using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.LiveTv;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Querying;
@@ -145,7 +146,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isDisliked,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] string? sortBy,
@@ -262,7 +263,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? seriesTimerId,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool? isMovie,
@@ -349,7 +350,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? seriesTimerId,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? fields,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool enableTotalRecordCount = true)
@@ -560,7 +561,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? genreIds,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] string? seriesTimerId,
             [FromQuery] Guid? librarySeriesId,
@@ -704,7 +705,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isSports,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? genreIds,
             [FromQuery] string? fields,
             [FromQuery] bool? enableUserData,

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

@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -99,7 +100,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,

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

@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -99,7 +100,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,

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

@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Playlists;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
@@ -151,7 +152,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes)
+            [FromQuery] ImageType[] enableImageTypes)
         {
             var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
             if (playlist == null)

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

@@ -36,7 +36,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>The list of scheduled tasks.</returns>
         [HttpGet]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public IEnumerable<IScheduledTaskWorker> GetTasks(
+        public IEnumerable<TaskInfo> GetTasks(
             [FromQuery] bool? isHidden,
             [FromQuery] bool? isEnabled)
         {
@@ -57,7 +57,7 @@ namespace Jellyfin.Api.Controllers
                     }
                 }
 
-                yield return task;
+                yield return ScheduledTaskHelpers.GetTaskInfo(task);
             }
         }
 

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

@@ -9,6 +9,7 @@ using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -98,7 +99,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,

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

@@ -150,7 +150,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] ItemFilter[] filters,
             [FromQuery] bool? isFavorite,
             [FromQuery] string? mediaTypes,
-            [FromQuery] string? imageTypes,
+            [FromQuery] ImageType[] imageTypes,
             [FromQuery] string? sortBy,
             [FromQuery] bool? isPlayed,
             [FromQuery] string? genres,
@@ -159,7 +159,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? years,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] string? person,
             [FromQuery] string? personIds,
             [FromQuery] string? personTypes,

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

@@ -13,6 +13,7 @@ using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.TV;
 using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -77,7 +78,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? parentId,
             [FromQuery] bool? enableImges,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] bool enableTotalRecordCount = true)
         {
@@ -134,7 +135,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? parentId,
             [FromQuery] bool? enableImges,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData)
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -206,7 +207,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? limit,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] string? sortBy)
         {
@@ -325,7 +326,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? adjacentTo,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData)
         {
             var user = userId.HasValue && !userId.Equals(Guid.Empty)

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

@@ -381,17 +381,13 @@ namespace Jellyfin.Api.Controllers
 
             var user = _userManager.GetUserById(userId);
 
-            if (string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
-            {
-                await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
-                _userManager.UpdateConfiguration(user.Id, updateUser.Configuration);
-            }
-            else
+            if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
             {
                 await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
-                _userManager.UpdateConfiguration(updateUser.Id, updateUser.Configuration);
             }
 
+            await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false);
+
             return NoContent();
         }
 
@@ -409,7 +405,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status400BadRequest)]
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        public ActionResult UpdateUserPolicy(
+        public async Task<ActionResult> UpdateUserPolicy(
             [FromRoute, Required] Guid userId,
             [FromBody] UserPolicy newPolicy)
         {
@@ -447,7 +443,7 @@ namespace Jellyfin.Api.Controllers
                 _sessionManager.RevokeUserTokens(user.Id, currentToken);
             }
 
-            _userManager.UpdatePolicy(userId, newPolicy);
+            await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false);
 
             return NoContent();
         }
@@ -464,7 +460,7 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
-        public ActionResult UpdateUserConfiguration(
+        public async Task<ActionResult> UpdateUserConfiguration(
             [FromRoute, Required] Guid userId,
             [FromBody] UserConfiguration userConfig)
         {
@@ -473,7 +469,7 @@ namespace Jellyfin.Api.Controllers
                 return Forbid("User configuration update not allowed");
             }
 
-            _userManager.UpdateConfiguration(userId, userConfig);
+            await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false);
 
             return NoContent();
         }

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

@@ -272,7 +272,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? isPlayed,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] bool? enableUserData,
             [FromQuery] int limit = 20,
             [FromQuery] bool groupItems = true)

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

@@ -371,7 +371,7 @@ namespace Jellyfin.Api.Controllers
 
             var baseUrlParam = string.Format(
                 CultureInfo.InvariantCulture,
-                "\"hls{0}\"",
+                "\"hls/{0}/\"",
                 Path.GetFileNameWithoutExtension(outputPath));
 
             return string.Format(

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

@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -77,7 +78,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] string? sortBy,
             [FromQuery] bool? enableUserData,
             [FromQuery] int? imageTypeLimit,
-            [FromQuery] string? enableImageTypes,
+            [FromQuery] ImageType[] enableImageTypes,
             [FromQuery] Guid? userId,
             [FromQuery] bool recursive = true,
             [FromQuery] bool? enableImages = true)

+ 3 - 5
Jellyfin.Api/Extensions/DtoExtensions.cs

@@ -126,7 +126,7 @@ namespace Jellyfin.Api.Extensions
             bool? enableImages,
             bool? enableUserData,
             int? imageTypeLimit,
-            string? enableImageTypes)
+            ImageType[] enableImageTypes)
         {
             dtoOptions.EnableImages = enableImages ?? true;
 
@@ -140,11 +140,9 @@ namespace Jellyfin.Api.Extensions
                 dtoOptions.EnableUserData = enableUserData.Value;
             }
 
-            if (!string.IsNullOrWhiteSpace(enableImageTypes))
+            if (enableImageTypes.Length != 0)
             {
-                dtoOptions.ImageTypes = enableImageTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
-                    .Select(v => (ImageType)Enum.Parse(typeof(ImageType), v, true))
-                    .ToArray();
+                dtoOptions.ImageTypes = enableImageTypes;
             }
 
             return dtoOptions;

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

@@ -155,7 +155,7 @@ namespace Jellyfin.Api.Helpers
                 return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8"));
             }
 
-            var totalBitrate = state.OutputAudioBitrate ?? 0 + state.OutputVideoBitrate ?? 0;
+            var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0);
 
             var builder = new StringBuilder();
 

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

@@ -6,6 +6,7 @@ using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Http;
 
@@ -161,5 +162,32 @@ namespace Jellyfin.Api.Helpers
                 .Select(i => i!.Value)
                 .ToArray();
         }
+
+        /// <summary>
+        /// Gets the item fields.
+        /// </summary>
+        /// <param name="imageTypes">The image types string.</param>
+        /// <returns>IEnumerable{ItemFields}.</returns>
+        internal static ImageType[] GetImageTypes(string? imageTypes)
+        {
+            if (string.IsNullOrEmpty(imageTypes))
+            {
+                return Array.Empty<ImageType>();
+            }
+
+            return Split(imageTypes, ',', true)
+                .Select(v =>
+                {
+                    if (Enum.TryParse(v, true, out ImageType value))
+                    {
+                        return (ImageType?)value;
+                    }
+
+                    return null;
+                })
+                .Where(i => i.HasValue)
+                .Select(i => i!.Value)
+                .ToArray();
+        }
     }
 }

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

@@ -1,4 +1,8 @@
 using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+using MediaBrowser.Common.Json.Converters;
+using MediaBrowser.Model.Entities;
 
 namespace Jellyfin.Api.Models.LiveTvDtos
 {
@@ -137,7 +141,9 @@ namespace Jellyfin.Api.Models.LiveTvDtos
         /// Gets or sets the image types to include in the output.
         /// Optional.
         /// </summary>
-        public string? EnableImageTypes { get; set; }
+        [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+        [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "EnableImageTypes", Justification = "Imported from ServiceStack")]
+        public ImageType[] EnableImageTypes { get; set; } = Array.Empty<ImageType>();
 
         /// <summary>
         /// Gets or sets include user data.

+ 44 - 74
Jellyfin.Server.Implementations/Users/UserManager.cs

@@ -2,6 +2,7 @@
 #pragma warning disable CA1307
 
 using System;
+using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
@@ -48,6 +49,8 @@ namespace Jellyfin.Server.Implementations.Users
         private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider;
         private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
 
+        private readonly IDictionary<Guid, User> _users;
+
         /// <summary>
         /// Initializes a new instance of the <see cref="UserManager"/> class.
         /// </summary>
@@ -81,38 +84,28 @@ namespace Jellyfin.Server.Implementations.Users
             _invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
             _defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
             _defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
+
+            _users = new ConcurrentDictionary<Guid, User>();
+            using var dbContext = _dbProvider.CreateContext();
+            foreach (var user in dbContext.Users
+                .Include(user => user.Permissions)
+                .Include(user => user.Preferences)
+                .Include(user => user.AccessSchedules)
+                .Include(user => user.ProfileImage)
+                .AsEnumerable())
+            {
+                _users.Add(user.Id, user);
+            }
         }
 
         /// <inheritdoc/>
         public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
 
         /// <inheritdoc/>
-        public IEnumerable<User> Users
-        {
-            get
-            {
-                using var dbContext = _dbProvider.CreateContext();
-                return dbContext.Users
-                    .Include(user => user.Permissions)
-                    .Include(user => user.Preferences)
-                    .Include(user => user.AccessSchedules)
-                    .Include(user => user.ProfileImage)
-                    .ToList();
-            }
-        }
+        public IEnumerable<User> Users => _users.Values;
 
         /// <inheritdoc/>
-        public IEnumerable<Guid> UsersIds
-        {
-            get
-            {
-                using var dbContext = _dbProvider.CreateContext();
-                return dbContext.Users
-                    .AsQueryable()
-                    .Select(user => user.Id)
-                    .ToList();
-            }
-        }
+        public IEnumerable<Guid> UsersIds => _users.Keys;
 
         /// <inheritdoc/>
         public User? GetUserById(Guid id)
@@ -122,13 +115,8 @@ namespace Jellyfin.Server.Implementations.Users
                 throw new ArgumentException("Guid can't be empty", nameof(id));
             }
 
-            using var dbContext = _dbProvider.CreateContext();
-            return dbContext.Users
-                .Include(user => user.Permissions)
-                .Include(user => user.Preferences)
-                .Include(user => user.AccessSchedules)
-                .Include(user => user.ProfileImage)
-                .FirstOrDefault(user => user.Id == id);
+            _users.TryGetValue(id, out var user);
+            return user;
         }
 
         /// <inheritdoc/>
@@ -139,14 +127,7 @@ namespace Jellyfin.Server.Implementations.Users
                 throw new ArgumentException("Invalid username", nameof(name));
             }
 
-            using var dbContext = _dbProvider.CreateContext();
-            return dbContext.Users
-                .Include(user => user.Permissions)
-                .Include(user => user.Preferences)
-                .Include(user => user.AccessSchedules)
-                .Include(user => user.ProfileImage)
-                .AsEnumerable()
-                .FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
+            return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
         }
 
         /// <inheritdoc/>
@@ -205,13 +186,17 @@ namespace Jellyfin.Server.Implementations.Users
                 ? await dbContext.Users.AsQueryable().Select(u => u.InternalId).MaxAsync().ConfigureAwait(false)
                 : 0;
 
-            return new User(
+            var user = new User(
                 name,
                 _defaultAuthenticationProvider.GetType().FullName,
                 _defaultPasswordResetProvider.GetType().FullName)
             {
                 InternalId = max + 1
             };
+
+            _users.Add(user.Id, user);
+
+            return user;
         }
 
         /// <inheritdoc/>
@@ -237,28 +222,12 @@ namespace Jellyfin.Server.Implementations.Users
         /// <inheritdoc/>
         public void DeleteUser(Guid userId)
         {
-            using var dbContext = _dbProvider.CreateContext();
-            var user = dbContext.Users
-                .Include(u => u.Permissions)
-                .Include(u => u.Preferences)
-                .Include(u => u.AccessSchedules)
-                .Include(u => u.ProfileImage)
-                .FirstOrDefault(u => u.Id == userId);
-            if (user == null)
+            if (!_users.TryGetValue(userId, out var user))
             {
                 throw new ResourceNotFoundException(nameof(userId));
             }
 
-            if (dbContext.Users.Find(user.Id) == null)
-            {
-                throw new ArgumentException(string.Format(
-                    CultureInfo.InvariantCulture,
-                    "The user cannot be deleted because there is no user with the Name {0} and Id {1}.",
-                    user.Username,
-                    user.Id));
-            }
-
-            if (dbContext.Users.Count() == 1)
+            if (_users.Count == 1)
             {
                 throw new InvalidOperationException(string.Format(
                     CultureInfo.InvariantCulture,
@@ -277,6 +246,8 @@ namespace Jellyfin.Server.Implementations.Users
                     nameof(userId));
             }
 
+            using var dbContext = _dbProvider.CreateContext();
+
             // Clear all entities related to the user from the database.
             if (user.ProfileImage != null)
             {
@@ -288,6 +259,7 @@ namespace Jellyfin.Server.Implementations.Users
             dbContext.RemoveRange(user.AccessSchedules);
             dbContext.Users.Remove(user);
             dbContext.SaveChanges();
+            _users.Remove(userId);
 
             _eventManager.Publish(new UserDeletedEventArgs(user));
         }
@@ -460,11 +432,9 @@ namespace Jellyfin.Server.Implementations.Users
                     // the authentication provider might have created it
                     user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
 
-                    if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy)
+                    if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user != null)
                     {
-                        UpdatePolicy(user.Id, hasNewUserPolicy.GetNewUserPolicy());
-
-                        await UpdateUserAsync(user).ConfigureAwait(false);
+                        await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
                     }
                 }
             }
@@ -589,9 +559,7 @@ namespace Jellyfin.Server.Implementations.Users
         public async Task InitializeAsync()
         {
             // TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
-            await using var dbContext = _dbProvider.CreateContext();
-
-            if (await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false))
+            if (_users.Any())
             {
                 return;
             }
@@ -604,6 +572,7 @@ namespace Jellyfin.Server.Implementations.Users
 
             _logger.LogWarning("No users, creating one with username {UserName}", defaultName);
 
+            await using var dbContext = _dbProvider.CreateContext();
             var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
             newUser.SetPermission(PermissionKind.IsAdministrator, true);
             newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
@@ -644,9 +613,9 @@ namespace Jellyfin.Server.Implementations.Users
         }
 
         /// <inheritdoc/>
-        public void UpdateConfiguration(Guid userId, UserConfiguration config)
+        public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
         {
-            using var dbContext = _dbProvider.CreateContext();
+            await using var dbContext = _dbProvider.CreateContext();
             var user = dbContext.Users
                            .Include(u => u.Permissions)
                            .Include(u => u.Preferences)
@@ -673,13 +642,13 @@ namespace Jellyfin.Server.Implementations.Users
             user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
 
             dbContext.Update(user);
-            dbContext.SaveChanges();
+            await dbContext.SaveChangesAsync().ConfigureAwait(false);
         }
 
         /// <inheritdoc/>
-        public void UpdatePolicy(Guid userId, UserPolicy policy)
+        public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
         {
-            using var dbContext = _dbProvider.CreateContext();
+            await using var dbContext = _dbProvider.CreateContext();
             var user = dbContext.Users
                            .Include(u => u.Permissions)
                            .Include(u => u.Preferences)
@@ -744,15 +713,16 @@ namespace Jellyfin.Server.Implementations.Users
             user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
 
             dbContext.Update(user);
-            dbContext.SaveChanges();
+            await dbContext.SaveChangesAsync().ConfigureAwait(false);
         }
 
         /// <inheritdoc/>
-        public void ClearProfileImage(User user)
+        public async Task ClearProfileImageAsync(User user)
         {
-            using var dbContext = _dbProvider.CreateContext();
+            await using var dbContext = _dbProvider.CreateContext();
             dbContext.Remove(user.ProfileImage);
-            dbContext.SaveChanges();
+            await dbContext.SaveChangesAsync().ConfigureAwait(false);
+            user.ProfileImage = null;
         }
 
         private static bool IsValidUsername(string name)

+ 1 - 1
Jellyfin.Server/Jellyfin.Server.csproj

@@ -42,7 +42,7 @@
     <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.9" />
     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.9" />
     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="3.1.9" />
-    <PackageReference Include="prometheus-net" Version="3.6.0" />
+    <PackageReference Include="prometheus-net" Version="4.0.0" />
     <PackageReference Include="prometheus-net.AspNetCore" Version="3.6.0" />
     <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
     <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />

+ 53 - 0
MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs

@@ -0,0 +1,53 @@
+using System;
+using System.ComponentModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+    /// <summary>
+    /// Convert comma delimited string to array of type.
+    /// </summary>
+    /// <typeparam name="T">Type to convert to.</typeparam>
+    public class JsonCommaDelimitedArrayConverter<T> : JsonConverter<T[]>
+    {
+        private readonly TypeConverter _typeConverter;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="JsonCommaDelimitedArrayConverter{T}"/> class.
+        /// </summary>
+        public JsonCommaDelimitedArrayConverter()
+        {
+            _typeConverter = TypeDescriptor.GetConverter(typeof(T));
+        }
+
+        /// <inheritdoc />
+        public override T[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+        {
+            if (reader.TokenType == JsonTokenType.String)
+            {
+                var stringEntries = reader.GetString()?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
+                if (stringEntries == null || stringEntries.Length == 0)
+                {
+                    return Array.Empty<T>();
+                }
+
+                var entries = new T[stringEntries.Length];
+                for (var i = 0; i < stringEntries.Length; i++)
+                {
+                    entries[i] = (T)_typeConverter.ConvertFrom(stringEntries[i].Trim());
+                }
+
+                return entries;
+            }
+
+            return JsonSerializer.Deserialize<T[]>(ref reader, options);
+        }
+
+        /// <inheritdoc />
+        public override void Write(Utf8JsonWriter writer, T[] value, JsonSerializerOptions options)
+        {
+            JsonSerializer.Serialize(writer, value, options);
+        }
+    }
+}

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

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

+ 6 - 3
MediaBrowser.Controller/Library/IUserManager.cs

@@ -158,7 +158,8 @@ namespace MediaBrowser.Controller.Library
         /// </summary>
         /// <param name="userId">The user's Id.</param>
         /// <param name="config">The request containing the new user configuration.</param>
-        void UpdateConfiguration(Guid userId, UserConfiguration config);
+        /// <returns>A task representing the update.</returns>
+        Task UpdateConfigurationAsync(Guid userId, UserConfiguration config);
 
         /// <summary>
         /// This method updates the user's policy.
@@ -167,12 +168,14 @@ namespace MediaBrowser.Controller.Library
         /// </summary>
         /// <param name="userId">The user's Id.</param>
         /// <param name="policy">The request containing the new user policy.</param>
-        void UpdatePolicy(Guid userId, UserPolicy policy);
+        /// <returns>A task representing the update.</returns>
+        Task UpdatePolicyAsync(Guid userId, UserPolicy policy);
 
         /// <summary>
         /// Clears the user's profile image.
         /// </summary>
         /// <param name="user">The user.</param>
-        void ClearProfileImage(User user);
+        /// <returns>A task representing the clearing of the profile image.</returns>
+        Task ClearProfileImageAsync(User user);
     }
 }

+ 18 - 2
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -1380,24 +1380,40 @@ namespace MediaBrowser.Controller.MediaEncoding
 
         public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream)
         {
+            if (audioStream == null)
+            {
+                return null;
+            }
+
             if (request.AudioBitRate.HasValue)
             {
                 // Don't encode any higher than this
                 return Math.Min(384000, request.AudioBitRate.Value);
             }
 
-            return null;
+            // Empty bitrate area is not allow on iOS
+            // Default audio bitrate to 128K if it is not being requested
+            // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options
+            return 128000;
         }
 
         public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream)
         {
+            if (audioStream == null)
+            {
+                return null;
+            }
+
             if (audioBitRate.HasValue)
             {
                 // Don't encode any higher than this
                 return Math.Min(384000, audioBitRate.Value);
             }
 
-            return null;
+            // Empty bitrate area is not allow on iOS
+            // Default audio bitrate to 128K if it is not being requested
+            // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options
+            return 128000;
         }
 
         public string GetAudioFilterParam(EncodingJobInfo state, EncodingOptions encodingOptions, bool isHls)

+ 10 - 0
MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs

@@ -666,6 +666,16 @@ namespace MediaBrowser.MediaEncoding.Probing
                 stream.AverageFrameRate = GetFrameRate(streamInfo.AverageFrameRate);
                 stream.RealFrameRate = GetFrameRate(streamInfo.RFrameRate);
 
+                // Interlaced video streams in Matroska containers return the field rate instead of the frame rate
+                // as both the average and real frame rate, so we half the returned frame rates to get the correct values
+                //
+                // https://gitlab.com/mbunkus/mkvtoolnix/-/wikis/Wrong-frame-rate-displayed
+                if (stream.IsInterlaced && formatInfo.FormatName.Contains("matroska", StringComparison.OrdinalIgnoreCase))
+                {
+                    stream.AverageFrameRate /= 2;
+                    stream.RealFrameRate /= 2;
+                }
+
                 if (isAudio || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) ||
                     string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase))
                 {

+ 1 - 1
tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj

@@ -22,7 +22,7 @@
     <PackageReference Include="xunit" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />
-    <PackageReference Include="Moq" Version="4.14.6" />
+    <PackageReference Include="Moq" Version="4.14.7" />
   </ItemGroup>
 
   <!-- Code Analyzers -->

+ 92 - 0
tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs

@@ -0,0 +1,92 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Jellyfin.Common.Tests.Models;
+using MediaBrowser.Model.Session;
+using Xunit;
+
+namespace Jellyfin.Common.Tests.Json
+{
+    public static class JsonCommaDelimitedArrayTests
+    {
+        [Fact]
+        public static void Deserialize_String_Valid_Success()
+        {
+            var desiredValue = new GenericBodyModel<string>
+            {
+                Value = new[] { "a", "b", "c" }
+            };
+
+            var options = new JsonSerializerOptions();
+            var value = JsonSerializer.Deserialize<GenericBodyModel<string>>(@"{ ""Value"": ""a,b,c"" }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+
+        [Fact]
+        public static void Deserialize_String_Space_Valid_Success()
+        {
+            var desiredValue = new GenericBodyModel<string>
+            {
+                Value = new[] { "a", "b", "c" }
+            };
+
+            var options = new JsonSerializerOptions();
+            var value = JsonSerializer.Deserialize<GenericBodyModel<string>>(@"{ ""Value"": ""a, b, c"" }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+
+        [Fact]
+        public static void Deserialize_GenericCommandType_Valid_Success()
+        {
+            var desiredValue = new GenericBodyModel<GeneralCommandType>
+            {
+                Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+            };
+
+            var options = new JsonSerializerOptions();
+            options.Converters.Add(new JsonStringEnumConverter());
+            var value = JsonSerializer.Deserialize<GenericBodyModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp,MoveDown"" }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+
+        [Fact]
+        public static void Deserialize_GenericCommandType_Space_Valid_Success()
+        {
+            var desiredValue = new GenericBodyModel<GeneralCommandType>
+            {
+                Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+            };
+
+            var options = new JsonSerializerOptions();
+            options.Converters.Add(new JsonStringEnumConverter());
+            var value = JsonSerializer.Deserialize<GenericBodyModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp, MoveDown"" }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+
+        [Fact]
+        public static void Deserialize_String_Array_Valid_Success()
+        {
+            var desiredValue = new GenericBodyModel<string>
+            {
+                Value = new[] { "a", "b", "c" }
+            };
+
+            var options = new JsonSerializerOptions();
+            var value = JsonSerializer.Deserialize<GenericBodyModel<string>>(@"{ ""Value"": [""a"",""b"",""c""] }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+
+        [Fact]
+        public static void Deserialize_GenericCommandType_Array_Valid_Success()
+        {
+            var desiredValue = new GenericBodyModel<GeneralCommandType>
+            {
+                Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+            };
+
+            var options = new JsonSerializerOptions();
+            options.Converters.Add(new JsonStringEnumConverter());
+            var value = JsonSerializer.Deserialize<GenericBodyModel<GeneralCommandType>>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", options);
+            Assert.Equal(desiredValue.Value, value?.Value);
+        }
+    }
+}

+ 20 - 0
tests/Jellyfin.Common.Tests/Models/GenericBodyModel.cs

@@ -0,0 +1,20 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+using MediaBrowser.Common.Json.Converters;
+
+namespace Jellyfin.Common.Tests.Models
+{
+    /// <summary>
+    /// The generic body model.
+    /// </summary>
+    /// <typeparam name="T">The value type.</typeparam>
+    public class GenericBodyModel<T>
+    {
+        /// <summary>
+        /// Gets or sets the value.
+        /// </summary>
+        [SuppressMessage("Microsoft.Performance", "CA1819:Properties should not return arrays", MessageId = "Value", Justification = "Imported from ServiceStack")]
+        [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+        public T[] Value { get; set; } = default!;
+    }
+}

+ 1 - 1
tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj

@@ -17,7 +17,7 @@
     <PackageReference Include="AutoFixture" Version="4.13.0" />
     <PackageReference Include="AutoFixture.AutoMoq" Version="4.13.0" />
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
-    <PackageReference Include="Moq" Version="4.14.6" />
+    <PackageReference Include="Moq" Version="4.14.7" />
     <PackageReference Include="xunit" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />