Bläddra i källkod

Merge branch 'master' into PluginDowngrade

BaronGreenback 4 år sedan
förälder
incheckning
67c480ad53
100 ändrade filer med 921 tillägg och 511 borttagningar
  1. 11 2
      Emby.Dlna/PlayTo/PlayToController.cs
  2. 39 13
      Emby.Server.Implementations/ApplicationHost.cs
  3. 0 26
      Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs
  4. 8 2
      Emby.Server.Implementations/Dto/DtoService.cs
  5. 1 1
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  6. 2 2
      Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
  7. 0 130
      Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs
  8. 12 13
      Emby.Server.Implementations/Library/LibraryManager.cs
  9. 1 1
      Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
  10. 2 2
      Emby.Server.Implementations/Library/UserViewManager.cs
  11. 17 15
      Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
  12. 2 2
      Emby.Server.Implementations/LiveTv/LiveTvManager.cs
  13. 21 0
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/Channels.cs
  14. 40 0
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs
  15. 15 62
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
  16. 3 1
      Emby.Server.Implementations/Localization/Core/fa.json
  17. 35 31
      Emby.Server.Implementations/Localization/Core/fr-CA.json
  18. 12 7
      Emby.Server.Implementations/Localization/Core/lv.json
  19. 6 1
      Emby.Server.Implementations/Localization/Core/sl-SI.json
  20. 2 0
      Emby.Server.Implementations/Properties/AssemblyInfo.cs
  21. 16 0
      Emby.Server.Implementations/Session/SessionManager.cs
  22. 2 0
      Emby.Server.Implementations/Session/SessionWebSocketListener.cs
  23. 47 3
      Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
  24. 2 1
      Emby.Server.Implementations/Updates/InstallationManager.cs
  25. 50 3
      Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
  26. 3 11
      Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs
  27. 14 4
      Jellyfin.Api/Constants/Policies.cs
  28. 7 15
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  29. 4 4
      Jellyfin.Api/Controllers/ImageController.cs
  30. 2 1
      Jellyfin.Api/Controllers/MediaInfoController.cs
  31. 23 5
      Jellyfin.Api/Controllers/PlaylistsController.cs
  32. 2 2
      Jellyfin.Api/Controllers/QuickConnectController.cs
  33. 21 4
      Jellyfin.Api/Controllers/SyncPlayController.cs
  34. 17 27
      Jellyfin.Api/Controllers/UserController.cs
  35. 3 3
      Jellyfin.Api/Controllers/VideosController.cs
  36. 1 1
      Jellyfin.Api/Jellyfin.Api.csproj
  37. 1 1
      Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
  38. 3 2
      Jellyfin.Data/Entities/ActivityLog.cs
  39. 0 1
      Jellyfin.Data/Entities/ItemDisplayPreferences.cs
  40. 2 2
      Jellyfin.Data/Entities/User.cs
  41. 28 0
      Jellyfin.Data/Enums/SyncPlayAccessRequirementType.cs
  42. 2 2
      Jellyfin.Data/Enums/SyncPlayUserAccessType.cs
  43. 88 13
      Jellyfin.Data/Enums/ViewType.cs
  44. 2 2
      Jellyfin.Data/Jellyfin.Data.csproj
  45. 1 1
      Jellyfin.Drawing.Skia/SkiaEncoder.cs
  46. 11 1
      Jellyfin.Networking/Configuration/NetworkConfiguration.cs
  47. 1 3
      Jellyfin.Networking/Manager/NetworkManager.cs
  48. 1 1
      Jellyfin.Server.Implementations/Activity/ActivityManager.cs
  49. 1 1
      Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs
  50. 1 1
      Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
  51. 3 2
      Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
  52. 1 0
      Jellyfin.Server.Implementations/JellyfinDb.cs
  53. 0 2
      Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
  54. 0 2
      Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
  55. 1 2
      Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs
  56. 0 2
      Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
  57. 5 6
      Jellyfin.Server.Implementations/Users/UserManager.cs
  58. 2 2
      Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs
  59. 23 0
      Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
  60. 30 7
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  61. 2 1
      Jellyfin.Server/Filters/FileResponseFilter.cs
  62. 2 2
      Jellyfin.Server/Jellyfin.Server.csproj
  63. 54 0
      Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs
  64. 47 0
      Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs
  65. 8 1
      Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
  66. 4 0
      Jellyfin.Server/Startup.cs
  67. 30 0
      MediaBrowser.Common/Json/Converters/JsonBoolNumberConverter.cs
  68. 2 9
      MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs
  69. 1 0
      MediaBrowser.Common/Json/JsonDefaults.cs
  70. 4 4
      MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs
  71. 1 0
      MediaBrowser.Controller/Entities/BaseItem.cs
  72. 5 0
      MediaBrowser.Controller/Entities/Folder.cs
  73. 1 1
      MediaBrowser.Controller/Library/ILibraryManager.cs
  74. 2 1
      MediaBrowser.Controller/Library/IUserManager.cs
  75. 11 0
      MediaBrowser.Controller/Session/ISessionManager.cs
  76. 7 0
      MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
  77. 5 2
      MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
  78. 2 2
      MediaBrowser.Model/Configuration/EncodingOptions.cs
  79. 5 5
      MediaBrowser.Model/Dto/BaseItemDto.cs
  80. 2 2
      MediaBrowser.Model/Users/UserPolicy.cs
  81. 4 3
      MediaBrowser.Providers/Manager/MetadataService.cs
  82. 1 1
      MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
  83. 1 7
      README.md
  84. 1 1
      deployment/Dockerfile.debian.amd64
  85. 1 1
      deployment/Dockerfile.debian.arm64
  86. 1 1
      deployment/Dockerfile.debian.armhf
  87. 1 1
      deployment/Dockerfile.linux.amd64
  88. 1 1
      deployment/Dockerfile.macos
  89. 1 1
      deployment/Dockerfile.portable
  90. 1 1
      deployment/Dockerfile.ubuntu.amd64
  91. 1 1
      deployment/Dockerfile.ubuntu.arm64
  92. 1 1
      deployment/Dockerfile.ubuntu.armhf
  93. 1 1
      deployment/Dockerfile.windows.amd64
  94. 2 2
      tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
  95. 1 1
      tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj
  96. 34 0
      tests/Jellyfin.Common.Tests/Json/JsonBoolNumberTests.cs
  97. 20 3
      tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs
  98. 1 1
      tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj
  99. 1 1
      tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj
  100. 1 1
      tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj

+ 11 - 2
Emby.Dlna/PlayTo/PlayToController.cs

@@ -340,10 +340,19 @@ namespace Emby.Dlna.PlayTo
             }
 
             var playlist = new PlaylistItem[len];
-            playlist[0] = CreatePlaylistItem(items[0], user, command.StartPositionTicks.Value, command.MediaSourceId, command.AudioStreamIndex, command.SubtitleStreamIndex);
+
+            // Not nullable enabled - so this is required.
+            playlist[0] = CreatePlaylistItem(
+                items[0],
+                user,
+                command.StartPositionTicks ?? 0,
+                command.MediaSourceId ?? string.Empty,
+                command.AudioStreamIndex,
+                command.SubtitleStreamIndex);
+
             for (int i = 1; i < len; i++)
             {
-                playlist[i] = CreatePlaylistItem(items[i], user, 0, null, null, null);
+                playlist[i] = CreatePlaylistItem(items[i], user, 0, string.Empty, null, null);
             }
 
             _logger.LogDebug("{0} - Playlist created", _session.DeviceName);

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

@@ -3,6 +3,7 @@
 using System;
 using System.Collections.Generic;
 using System.Diagnostics;
+using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Net;
@@ -275,13 +276,6 @@ namespace Emby.Server.Implementations
 
             fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem));
 
-            CertificateInfo = new CertificateInfo
-            {
-                Path = ServerConfigurationManager.Configuration.CertificatePath,
-                Password = ServerConfigurationManager.Configuration.CertificatePassword
-            };
-            Certificate = GetCertificate(CertificateInfo);
-
             ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version;
             ApplicationVersionString = ApplicationVersion.ToString(3);
             ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString;
@@ -496,6 +490,7 @@ namespace Emby.Server.Implementations
             Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false));
 
             ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated;
+            ConfigurationManager.NamedConfigurationUpdated += OnConfigurationUpdated;
 
             _mediaEncoder.SetFFmpegPath();
 
@@ -545,6 +540,13 @@ namespace Emby.Server.Implementations
                 HttpsPort = NetworkConfiguration.DefaultHttpsPort;
             }
 
+            CertificateInfo = new CertificateInfo
+            {
+                Path = networkConfiguration.CertificatePath,
+                Password = networkConfiguration.CertificatePassword
+            };
+            Certificate = GetCertificate(CertificateInfo);
+
             DiscoverTypes();
 
             RegisterServices();
@@ -754,7 +756,7 @@ namespace Emby.Server.Implementations
                 // Don't use an empty string password
                 var password = string.IsNullOrWhiteSpace(info.Password) ? null : info.Password;
 
-                var localCert = new X509Certificate2(certificateLocation, password);
+                var localCert = new X509Certificate2(certificateLocation, password, X509KeyStorageFlags.UserKeySet);
                 // localCert.PrivateKey = PrivateKey.CreateFromFile(pvk_file).RSA;
                 if (!localCert.HasPrivateKey)
                 {
@@ -911,11 +913,11 @@ namespace Emby.Server.Implementations
         protected void OnConfigurationUpdated(object sender, EventArgs e)
         {
             var requiresRestart = false;
+            var networkConfiguration = ServerConfigurationManager.GetNetworkConfiguration();
 
             // Don't do anything if these haven't been set yet
             if (HttpPort != 0 && HttpsPort != 0)
             {
-                var networkConfiguration = ServerConfigurationManager.GetNetworkConfiguration();
                 // Need to restart if ports have changed
                 if (networkConfiguration.HttpServerPortNumber != HttpPort ||
                     networkConfiguration.HttpsPortNumber != HttpsPort)
@@ -935,10 +937,7 @@ namespace Emby.Server.Implementations
                 requiresRestart = true;
             }
 
-            var currentCertPath = CertificateInfo?.Path;
-            var newCertPath = ServerConfigurationManager.Configuration.CertificatePath;
-
-            if (!string.Equals(currentCertPath, newCertPath, StringComparison.OrdinalIgnoreCase))
+            if (ValidateSslCertificate(networkConfiguration))
             {
                 requiresRestart = true;
             }
@@ -951,6 +950,33 @@ namespace Emby.Server.Implementations
             }
         }
 
+        /// <summary>
+        /// Validates the SSL certificate.
+        /// </summary>
+        /// <param name="networkConfig">The new configuration.</param>
+        /// <exception cref="FileNotFoundException">The certificate path doesn't exist.</exception>
+        private bool ValidateSslCertificate(NetworkConfiguration networkConfig)
+        {
+            var newPath = networkConfig.CertificatePath;
+
+            if (!string.IsNullOrWhiteSpace(newPath)
+                && !string.Equals(CertificateInfo?.Path, newPath, StringComparison.Ordinal))
+            {
+                if (File.Exists(newPath))
+                {
+                    return true;
+                }
+                
+                throw new FileNotFoundException(
+                    string.Format(
+                        CultureInfo.InvariantCulture,
+                        "Certificate file '{0}' does not exist.",
+                        newPath));
+            }
+
+            return false;
+        }
+
         /// <summary>
         /// Notifies that the kernel that a change has been made that requires a restart.
         /// </summary>

+ 0 - 26
Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs

@@ -88,38 +88,12 @@ namespace Emby.Server.Implementations.Configuration
             var newConfig = (ServerConfiguration)newConfiguration;
 
             ValidateMetadataPath(newConfig);
-            ValidateSslCertificate(newConfig);
 
             ConfigurationUpdating?.Invoke(this, new GenericEventArgs<ServerConfiguration>(newConfig));
 
             base.ReplaceConfiguration(newConfiguration);
         }
 
-        /// <summary>
-        /// Validates the SSL certificate.
-        /// </summary>
-        /// <param name="newConfig">The new configuration.</param>
-        /// <exception cref="FileNotFoundException">The certificate path doesn't exist.</exception>
-        private void ValidateSslCertificate(BaseApplicationConfiguration newConfig)
-        {
-            var serverConfig = (ServerConfiguration)newConfig;
-
-            var newPath = serverConfig.CertificatePath;
-
-            if (!string.IsNullOrWhiteSpace(newPath)
-                && !string.Equals(Configuration.CertificatePath, newPath, StringComparison.Ordinal))
-            {
-                if (!File.Exists(newPath))
-                {
-                    throw new FileNotFoundException(
-                        string.Format(
-                            CultureInfo.InvariantCulture,
-                            "Certificate file '{0}' does not exist.",
-                            newPath));
-                }
-            }
-        }
-
         /// <summary>
         /// Validates the metadata path.
         /// </summary>

+ 8 - 2
Emby.Server.Implementations/Dto/DtoService.cs

@@ -1138,7 +1138,10 @@ namespace Emby.Server.Implementations.Dto
                     if (episodeSeries != null)
                     {
                         dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, episodeSeries, ImageType.Primary);
-                        AttachPrimaryImageAspectRatio(dto, episodeSeries);
+                        if (!dto.ImageTags.ContainsKey(ImageType.Primary))
+                        {
+                            AttachPrimaryImageAspectRatio(dto, episodeSeries);
+                        }
                     }
                 }
 
@@ -1185,7 +1188,10 @@ namespace Emby.Server.Implementations.Dto
                     if (series != null)
                     {
                         dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, series, ImageType.Primary);
-                        AttachPrimaryImageAspectRatio(dto, series);
+                        if (!dto.ImageTags.ContainsKey(ImageType.Primary))
+                        {
+                            AttachPrimaryImageAspectRatio(dto, series);
+                        }
                     }
                 }
             }

+ 1 - 1
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -31,7 +31,7 @@
     <PackageReference Include="Microsoft.AspNetCore.ResponseCompression" 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.Extensions.DependencyInjection" Version="5.0.0" />
+    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
     <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" />

+ 2 - 2
Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs

@@ -185,11 +185,11 @@ namespace Emby.Server.Implementations.HttpServer.Security
                         updateToken = true;
                     }
 
-                    authInfo.IsApiKey = true;
+                    authInfo.IsApiKey = false;
                 }
                 else
                 {
-                    authInfo.IsApiKey = false;
+                    authInfo.IsApiKey = true;
                 }
 
                 if (updateToken)

+ 0 - 130
Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs

@@ -1,130 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Globalization;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Jellyfin.Data.Events;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Net;
-using Microsoft.Extensions.Logging;
-
-namespace Emby.Server.Implementations.Library
-{
-    /// <summary>
-    /// A library post scan/refresh task for pre-fetching remote images.
-    /// </summary>
-    public class ImageFetcherPostScanTask : ILibraryPostScanTask
-    {
-        private readonly ILibraryManager _libraryManager;
-        private readonly IProviderManager _providerManager;
-        private readonly ILogger<ImageFetcherPostScanTask> _logger;
-        private readonly SemaphoreSlim _imageFetcherLock;
-
-        private ConcurrentDictionary<Guid, (BaseItem item, ItemUpdateType updateReason)> _queuedItems;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ImageFetcherPostScanTask"/> class.
-        /// </summary>
-        /// <param name="libraryManager">An instance of <see cref="ILibraryManager"/>.</param>
-        /// <param name="providerManager">An instance of <see cref="IProviderManager"/>.</param>
-        /// <param name="logger">An instance of <see cref="ILogger{ImageFetcherPostScanTask}"/>.</param>
-        public ImageFetcherPostScanTask(
-            ILibraryManager libraryManager,
-            IProviderManager providerManager,
-            ILogger<ImageFetcherPostScanTask> logger)
-        {
-            _libraryManager = libraryManager;
-            _providerManager = providerManager;
-            _logger = logger;
-            _queuedItems = new ConcurrentDictionary<Guid, (BaseItem item, ItemUpdateType updateReason)>();
-            _imageFetcherLock = new SemaphoreSlim(1, 1);
-            _libraryManager.ItemAdded += OnLibraryManagerItemAddedOrUpdated;
-            _libraryManager.ItemUpdated += OnLibraryManagerItemAddedOrUpdated;
-            _providerManager.RefreshCompleted += OnProviderManagerRefreshCompleted;
-        }
-
-        /// <inheritdoc />
-        public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
-        {
-            // Sometimes a library scan will cause this to run twice if there's an item refresh going on.
-            await _imageFetcherLock.WaitAsync(cancellationToken).ConfigureAwait(false);
-
-            try
-            {
-                var now = DateTime.UtcNow;
-                var itemGuids = _queuedItems.Keys.ToList();
-
-                for (var i = 0; i < itemGuids.Count; i++)
-                {
-                    if (!_queuedItems.TryGetValue(itemGuids[i], out var queuedItem))
-                    {
-                        continue;
-                    }
-
-                    var itemId = queuedItem.item.Id.ToString("N", CultureInfo.InvariantCulture);
-                    var itemType = queuedItem.item.GetType();
-                    _logger.LogDebug(
-                        "Updating remote images for item {ItemId} with media type {ItemMediaType}",
-                        itemId,
-                        itemType);
-                    try
-                    {
-                        await _libraryManager.UpdateImagesAsync(queuedItem.item, queuedItem.updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false);
-                    }
-                    catch (Exception ex)
-                    {
-                        _logger.LogError(ex, "Failed to fetch images for {Type} item with id {ItemId}", itemType, itemId);
-                    }
-
-                    _queuedItems.TryRemove(queuedItem.item.Id, out _);
-                }
-
-                if (itemGuids.Count > 0)
-                {
-                    _logger.LogInformation(
-                        "Finished updating/pre-fetching {NumberOfImages} images. Elapsed time: {TimeElapsed}s.",
-                        itemGuids.Count.ToString(CultureInfo.InvariantCulture),
-                        (DateTime.UtcNow - now).TotalSeconds.ToString(CultureInfo.InvariantCulture));
-                }
-                else
-                {
-                    _logger.LogDebug("No images were updated.");
-                }
-            }
-            finally
-            {
-                _imageFetcherLock.Release();
-            }
-        }
-
-        private void OnLibraryManagerItemAddedOrUpdated(object sender, ItemChangeEventArgs itemChangeEventArgs)
-        {
-            if (!_queuedItems.ContainsKey(itemChangeEventArgs.Item.Id) && itemChangeEventArgs.Item.ImageInfos.Length > 0)
-            {
-                _queuedItems.AddOrUpdate(
-                    itemChangeEventArgs.Item.Id,
-                    (itemChangeEventArgs.Item, itemChangeEventArgs.UpdateReason),
-                    (key, existingValue) => existingValue);
-            }
-        }
-
-        private void OnProviderManagerRefreshCompleted(object sender, GenericEventArgs<BaseItem> e)
-        {
-            if (!_queuedItems.ContainsKey(e.Argument.Id) && e.Argument.ImageInfos.Length > 0)
-            {
-                _queuedItems.AddOrUpdate(
-                    e.Argument.Id,
-                    (e.Argument, ItemUpdateType.None),
-                    (key, existingValue) => existingValue);
-            }
-
-            // The RefreshCompleted event is a bit awkward in that it seems to _only_ be fired on
-            // the item that was refreshed regardless of children refreshes. So we take it as a signal
-            // that the refresh is entirely completed.
-            Run(null, CancellationToken.None).GetAwaiter().GetResult();
-        }
-    }
-}

+ 12 - 13
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -42,7 +42,6 @@ using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Library;
-using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Tasks;
 using MediaBrowser.Providers.MediaInfo;
@@ -1955,9 +1954,12 @@ namespace Emby.Server.Implementations.Library
         }
 
         /// <inheritdoc />
-        public Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
+        public async Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
         {
-            RunMetadataSavers(items, updateReason);
+            foreach (var item in items)
+            {
+                await RunMetadataSavers(item, updateReason).ConfigureAwait(false);
+            }
 
             _itemRepository.SaveItems(items, cancellationToken);
 
@@ -1988,25 +1990,22 @@ namespace Emby.Server.Implementations.Library
                     }
                 }
             }
-
-            return Task.CompletedTask;
         }
 
         /// <inheritdoc />
         public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
             => UpdateItemsAsync(new[] { item }, parent, updateReason, cancellationToken);
 
-        public void RunMetadataSavers(IReadOnlyList<BaseItem> items, ItemUpdateType updateReason)
+        public Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
         {
-            foreach (var item in items)
+            if (item.IsFileProtocol)
             {
-                if (item.IsFileProtocol)
-                {
-                    ProviderManager.SaveMetadata(item, updateReason);
-                }
-
-                item.DateLastSaved = DateTime.UtcNow;
+                ProviderManager.SaveMetadata(item, updateReason);
             }
+
+            item.DateLastSaved = DateTime.UtcNow;
+
+            return UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate);
         }
 
         /// <summary>

+ 1 - 1
Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs

@@ -11,7 +11,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
 {
     public class BookResolver : MediaBrowser.Controller.Resolvers.ItemResolver<Book>
     {
-        private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".opf", ".pdf" };
+        private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" };
 
         protected override Book Resolve(ItemResolveArgs args)
         {

+ 2 - 2
Emby.Server.Implementations/Library/UserViewManager.cs

@@ -139,13 +139,13 @@ namespace Emby.Server.Implementations.Library
             return list
                 .OrderBy(i =>
                 {
-                    var index = orders.IndexOf(i.Id.ToString("N", CultureInfo.InvariantCulture));
+                    var index = orders.IndexOf(i.Id.ToString("D", CultureInfo.InvariantCulture));
 
                     if (index == -1
                         && i is UserView view
                         && view.DisplayParentId != Guid.Empty)
                     {
-                        index = orders.IndexOf(view.DisplayParentId.ToString("N", CultureInfo.InvariantCulture));
+                        index = orders.IndexOf(view.DisplayParentId.ToString("D", CultureInfo.InvariantCulture));
                     }
 
                     return index == -1 ? int.MaxValue : index;

+ 17 - 15
Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs

@@ -611,25 +611,25 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             CancellationToken cancellationToken,
             HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
         {
-            try
+            var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+                .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false);
+            if (response.IsSuccessStatusCode)
             {
-                return await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false);
+                return response;
             }
-            catch (HttpRequestException ex)
-            {
-                _tokens.Clear();
 
-                if (!ex.StatusCode.HasValue || (int)ex.StatusCode.Value >= 500)
-                {
-                    enableRetry = false;
-                }
-
-                if (!enableRetry)
-                {
-                    throw;
-                }
+            // Response is automatically disposed in the calling function,
+            // so dispose manually if not returning.
+            response.Dispose();
+            if (!enableRetry || (int)response.StatusCode >= 500)
+            {
+                throw new HttpRequestException(
+                    string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
+                    null,
+                    response.StatusCode);
             }
 
+            _tokens.Clear();
             options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
             return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false);
         }
@@ -647,6 +647,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
 
             using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
+            response.EnsureSuccessStatusCode();
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Token>(stream).ConfigureAwait(false);
             if (string.Equals(root.message, "OK", StringComparison.Ordinal))
@@ -701,6 +702,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             try
             {
                 using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
+                httpResponse.EnsureSuccessStatusCode();
                 await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
                 using var response = httpResponse.Content;
                 var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Lineups>(stream).ConfigureAwait(false);
@@ -709,7 +711,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             }
             catch (HttpRequestException ex)
             {
-                // Apparently we're supposed to swallow this
+                // SchedulesDirect returns 400 if no lineups are configured.
                 if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest)
                 {
                     return false;

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

@@ -1928,7 +1928,7 @@ namespace Emby.Server.Implementations.LiveTv
 
                 foreach (var programDto in currentProgramDtos)
                 {
-                    if (currentChannelsDict.TryGetValue(programDto.ChannelId, out BaseItemDto channelDto))
+                    if (programDto.ChannelId.HasValue && currentChannelsDict.TryGetValue(programDto.ChannelId.Value, out BaseItemDto channelDto))
                     {
                         channelDto.CurrentProgram = programDto;
                     }
@@ -2018,7 +2018,7 @@ namespace Emby.Server.Implementations.LiveTv
             info.DayPattern = _tvDtoService.GetDayPattern(info.Days);
 
             info.Name = program.Name;
-            info.ChannelId = programDto.ChannelId;
+            info.ChannelId = programDto.ChannelId ?? Guid.Empty;
             info.ChannelName = programDto.ChannelName;
             info.StartDate = program.StartDate;
             info.Name = program.Name;

+ 21 - 0
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/Channels.cs

@@ -0,0 +1,21 @@
+namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
+{
+    internal class Channels
+    {
+        public string GuideNumber { get; set; }
+
+        public string GuideName { get; set; }
+
+        public string VideoCodec { get; set; }
+
+        public string AudioCodec { get; set; }
+
+        public string URL { get; set; }
+
+        public bool Favorite { get; set; }
+
+        public bool DRM { get; set; }
+
+        public bool HD { get; set; }
+    }
+}

+ 40 - 0
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs

@@ -0,0 +1,40 @@
+using System;
+
+namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
+{
+    internal class DiscoverResponse
+    {
+        public string FriendlyName { get; set; }
+
+        public string ModelNumber { get; set; }
+
+        public string FirmwareName { get; set; }
+
+        public string FirmwareVersion { get; set; }
+
+        public string DeviceID { get; set; }
+
+        public string DeviceAuth { get; set; }
+
+        public string BaseURL { get; set; }
+
+        public string LineupURL { get; set; }
+
+        public int TunerCount { get; set; }
+
+        public bool SupportsTranscoding
+        {
+            get
+            {
+                var model = ModelNumber ?? string.Empty;
+
+                if (model.IndexOf("hdtc", StringComparison.OrdinalIgnoreCase) != -1)
+                {
+                    return true;
+                }
+
+                return false;
+            }
+        }
+    }
+}

+ 15 - 62
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs

@@ -8,10 +8,12 @@ using System.Linq;
 using System.Net;
 using System.Net.Http;
 using System.Text.Json;
+using System.Text.Json.Serialization;
 using System.Threading;
 using System.Threading.Tasks;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Json;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
@@ -37,6 +39,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
         private readonly INetworkManager _networkManager;
         private readonly IStreamHelper _streamHelper;
 
+        private readonly JsonSerializerOptions _jsonOptions;
+
         private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>();
 
         public HdHomerunHost(
@@ -56,6 +60,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             _socketFactory = socketFactory;
             _networkManager = networkManager;
             _streamHelper = streamHelper;
+
+            _jsonOptions = JsonDefaults.GetOptions();
         }
 
         public string Name => "HD Homerun";
@@ -67,13 +73,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
         private string GetChannelId(TunerHostInfo info, Channels i)
             => ChannelIdPrefix + i.GuideNumber;
 
-        private async Task<List<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken)
+        internal async Task<List<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken)
         {
             var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false);
 
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-            var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, cancellationToken: cancellationToken)
+            var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, _jsonOptions, cancellationToken)
                 .ConfigureAwait(false) ?? new List<Channels>();
 
             if (info.ImportFavoritesOnly)
@@ -100,7 +106,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 Id = GetChannelId(info, i),
                 IsFavorite = i.Favorite,
                 TunerHostId = info.Id,
-                IsHD = i.HD == 1,
+                IsHD = i.HD,
                 AudioCodec = i.AudioCodec,
                 VideoCodec = i.VideoCodec,
                 ChannelType = ChannelType.TV,
@@ -109,7 +115,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             }).Cast<ChannelInfo>().ToList();
         }
 
-        private async Task<DiscoverResponse> GetModelInfo(TunerHostInfo info, bool throwAllExceptions, CancellationToken cancellationToken)
+        internal async Task<DiscoverResponse> GetModelInfo(TunerHostInfo info, bool throwAllExceptions, CancellationToken cancellationToken)
         {
             var cacheKey = info.Id;
 
@@ -127,10 +133,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             try
             {
                 using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
-                    .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/discover.json", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+                    .GetAsync(GetApiUrl(info) + "/discover.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken)
                     .ConfigureAwait(false);
+                response.EnsureSuccessStatusCode();
                 await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-                var discoverResponse = await JsonSerializer.DeserializeAsync<DiscoverResponse>(stream, cancellationToken: cancellationToken)
+                var discoverResponse = await JsonSerializer.DeserializeAsync<DiscoverResponse>(stream, _jsonOptions, cancellationToken)
                     .ConfigureAwait(false);
 
                 if (!string.IsNullOrEmpty(cacheKey))
@@ -328,25 +335,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             return new Uri(url).AbsoluteUri.TrimEnd('/');
         }
 
-        private class Channels
-        {
-            public string GuideNumber { get; set; }
-
-            public string GuideName { get; set; }
-
-            public string VideoCodec { get; set; }
-
-            public string AudioCodec { get; set; }
-
-            public string URL { get; set; }
-
-            public bool Favorite { get; set; }
-
-            public bool DRM { get; set; }
-
-            public int HD { get; set; }
-        }
-
         protected EncodingOptions GetEncodingOptions()
         {
             return Config.GetConfiguration<EncodingOptions>("encoding");
@@ -674,42 +662,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             }
         }
 
-        public class DiscoverResponse
-        {
-            public string FriendlyName { get; set; }
-
-            public string ModelNumber { get; set; }
-
-            public string FirmwareName { get; set; }
-
-            public string FirmwareVersion { get; set; }
-
-            public string DeviceID { get; set; }
-
-            public string DeviceAuth { get; set; }
-
-            public string BaseURL { get; set; }
-
-            public string LineupURL { get; set; }
-
-            public int TunerCount { get; set; }
-
-            public bool SupportsTranscoding
-            {
-                get
-                {
-                    var model = ModelNumber ?? string.Empty;
-
-                    if (model.IndexOf("hdtc", StringComparison.OrdinalIgnoreCase) != -1)
-                    {
-                        return true;
-                    }
-
-                    return false;
-                }
-            }
-        }
-
         public async Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken)
         {
             lock (_modelCache)
@@ -762,7 +714,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             return list;
         }
 
-        private async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken)
+        internal async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken)
         {
             var hostInfo = new TunerHostInfo
             {
@@ -774,6 +726,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
 
             hostInfo.DeviceId = modelInfo.DeviceID;
             hostInfo.FriendlyName = modelInfo.FriendlyName;
+            hostInfo.TunerCount = modelInfo.TunerCount;
 
             return hostInfo;
         }

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

@@ -113,5 +113,7 @@
     "TasksChannelsCategory": "کانال‌های داخلی",
     "TasksApplicationCategory": "برنامه",
     "TasksLibraryCategory": "کتابخانه",
-    "TasksMaintenanceCategory": "تعمیر"
+    "TasksMaintenanceCategory": "تعمیر",
+    "Forced": "اجباری",
+    "Default": "پیشفرض"
 }

+ 35 - 31
Emby.Server.Implementations/Localization/Core/fr-CA.json

@@ -1,9 +1,9 @@
 {
     "Albums": "Albums",
-    "AppDeviceValues": "Application : {0}, Appareil : {1}",
+    "AppDeviceValues": "App : {0}, Appareil : {1}",
     "Application": "Application",
     "Artists": "Artistes",
-    "AuthenticationSucceededWithUserName": "{0} s'est authentifié avec succès",
+    "AuthenticationSucceededWithUserName": "{0} authentifié avec succès",
     "Books": "Livres",
     "CameraImageUploadedFrom": "Une nouvelle image de caméra a été téléchargée depuis {0}",
     "Channels": "Chaînes",
@@ -11,12 +11,12 @@
     "Collections": "Collections",
     "DeviceOfflineWithName": "{0} s'est déconnecté",
     "DeviceOnlineWithName": "{0} est connecté",
-    "FailedLoginAttemptWithUserName": "Échec d'une tentative de connexion de {0}",
+    "FailedLoginAttemptWithUserName": "Tentative de connexion échoué par {0}",
     "Favorites": "Favoris",
     "Folders": "Dossiers",
     "Genres": "Genres",
     "HeaderAlbumArtists": "Artistes de l'album",
-    "HeaderContinueWatching": "Continuer à regarder",
+    "HeaderContinueWatching": "Reprendre le visionnement",
     "HeaderFavoriteAlbums": "Albums favoris",
     "HeaderFavoriteArtists": "Artistes favoris",
     "HeaderFavoriteEpisodes": "Épisodes favoris",
@@ -26,12 +26,12 @@
     "HeaderNextUp": "À Suivre",
     "HeaderRecordingGroups": "Groupes d'enregistrements",
     "HomeVideos": "Vidéos personnelles",
-    "Inherit": "Hériter",
+    "Inherit": "Hérite",
     "ItemAddedWithName": "{0} a été ajouté à la médiathèque",
     "ItemRemovedWithName": "{0} a été supprimé de la médiathèque",
     "LabelIpAddressValue": "Adresse IP : {0}",
     "LabelRunningTimeValue": "Durée : {0}",
-    "Latest": "Derniers",
+    "Latest": "Plus récent",
     "MessageApplicationUpdated": "Le serveur Jellyfin a été mis à jour",
     "MessageApplicationUpdatedTo": "Le serveur Jellyfin a été mis à jour vers la version {0}",
     "MessageNamedServerConfigurationUpdatedWithValue": "La configuration de la section {0} du serveur a été mise à jour",
@@ -40,15 +40,15 @@
     "Movies": "Films",
     "Music": "Musique",
     "MusicVideos": "Vidéos musicales",
-    "NameInstallFailed": "{0} échec d'installation",
+    "NameInstallFailed": "échec d'installation de {0}",
     "NameSeasonNumber": "Saison {0}",
     "NameSeasonUnknown": "Saison Inconnue",
-    "NewVersionIsAvailable": "Une nouvelle version du serveur Jellyfin est disponible au téléchargement.",
+    "NewVersionIsAvailable": "Une nouvelle version du serveur Jellyfin est disponible.",
     "NotificationOptionApplicationUpdateAvailable": "Mise à jour de l'application disponible",
     "NotificationOptionApplicationUpdateInstalled": "Mise à jour de l'application installée",
     "NotificationOptionAudioPlayback": "Lecture audio démarrée",
     "NotificationOptionAudioPlaybackStopped": "Lecture audio arrêtée",
-    "NotificationOptionCameraImageUploaded": "L'image de l'appareil photo a été transférée",
+    "NotificationOptionCameraImageUploaded": "Image d'appareil photo transférée",
     "NotificationOptionInstallationFailed": "Échec d'installation",
     "NotificationOptionNewLibraryContent": "Nouveau contenu ajouté",
     "NotificationOptionPluginError": "Erreur d'extension",
@@ -70,9 +70,9 @@
     "ScheduledTaskFailedWithName": "{0} a échoué",
     "ScheduledTaskStartedWithName": "{0} a commencé",
     "ServerNameNeedsToBeRestarted": "{0} doit être redémarré",
-    "Shows": "Émissions",
+    "Shows": "Séries",
     "Songs": "Chansons",
-    "StartupEmbyServerIsLoading": "Le serveur Jellyfin est en cours de chargement. Veuillez réessayer dans quelques instants.",
+    "StartupEmbyServerIsLoading": "Serveur Jellyfin en cours de chargement. Réessayez dans quelques instants.",
     "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
     "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}",
     "Sync": "Synchroniser",
@@ -80,39 +80,43 @@
     "TvShows": "Séries Télé",
     "User": "Utilisateur",
     "UserCreatedWithName": "L'utilisateur {0} a été créé",
-    "UserDeletedWithName": "L'utilisateur {0} a été supprimé",
-    "UserDownloadingItemWithValues": "{0} est en train de télécharger {1}",
+    "UserDeletedWithName": "L'utilisateur {0} supprimé",
+    "UserDownloadingItemWithValues": "{0} télécharge {1}",
     "UserLockedOutWithName": "L'utilisateur {0} a été verrouillé",
-    "UserOfflineFromDevice": "{0} s'est déconnecté depuis {1}",
-    "UserOnlineFromDevice": "{0} s'est connecté depuis {1}",
-    "UserPasswordChangedWithName": "Le mot de passe pour l'utilisateur {0} a été modifié",
+    "UserOfflineFromDevice": "{0} s'est déconnecté de {1}",
+    "UserOnlineFromDevice": "{0} s'est connecté de {1}",
+    "UserPasswordChangedWithName": "Le mot de passe de utilisateur {0} a été modifié",
     "UserPolicyUpdatedWithName": "La politique de l'utilisateur a été mise à jour pour {0}",
-    "UserStartedPlayingItemWithValues": "{0} est en train de lire {1} sur {2}",
-    "UserStoppedPlayingItemWithValues": "{0} vient d'arrêter la lecture de {1} sur {2}",
+    "UserStartedPlayingItemWithValues": "{0} joue {1} sur {2}",
+    "UserStoppedPlayingItemWithValues": "{0} a terminé la lecture de {1} sur {2}",
     "ValueHasBeenAddedToLibrary": "{0} a été ajouté à votre médiathèque",
     "ValueSpecialEpisodeName": "Spécial - {0}",
     "VersionNumber": "Version {0}",
-    "TasksLibraryCategory": "Bibliothèque",
+    "TasksLibraryCategory": "Médiathèque",
     "TasksMaintenanceCategory": "Entretien",
-    "TaskDownloadMissingSubtitlesDescription": "Recherche l'internet pour des sous-titres manquants à base de métadonnées configurées.",
+    "TaskDownloadMissingSubtitlesDescription": "Recherche les sous-titres manquant sur l'internet selon la configuration des métadonnées.",
     "TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquants",
-    "TaskRefreshChannelsDescription": "Rafraîchit des informations des chaines internet.",
-    "TaskRefreshChannels": "Rafraîchir des chaines",
-    "TaskCleanTranscodeDescription": "Supprime les fichiers de transcodage de plus d'un jour.",
+    "TaskRefreshChannelsDescription": "Rafraîchit les informations des chaines internet.",
+    "TaskRefreshChannels": "Rafraîchir les chaines",
+    "TaskCleanTranscodeDescription": "Supprime les fichiers de transcodage datant de plus d'un jour.",
     "TaskCleanTranscode": "Nettoyer le répertoire de transcodage",
-    "TaskUpdatePluginsDescription": "Télécharger et installer les mises à jours des extensions qui sont configurés pour les m.à.j. automisés.",
+    "TaskUpdatePluginsDescription": "Télécharge et installe les mises à jours des extensions configurés pour les m.à.j. automatiques.",
     "TaskUpdatePlugins": "Mise à jour des extensions",
-    "TaskRefreshPeopleDescription": "Met à jour les métadonnées pour les acteurs et réalisateurs dans votre bibliothèque de médias.",
-    "TaskRefreshPeople": "Rafraîchir les acteurs",
-    "TaskCleanLogsDescription": "Supprime les journaux qui ont plus que {0} jours.",
+    "TaskRefreshPeopleDescription": "Met à jour les métadonnées pour les acteurs et réalisateurs dans votre médiathèque.",
+    "TaskRefreshPeople": "Rafraîchir les personnes",
+    "TaskCleanLogsDescription": "Supprime les journaux plus vieux que {0} jours.",
     "TaskCleanLogs": "Nettoyer le répertoire des journaux",
-    "TaskRefreshLibraryDescription": "Analyse votre bibliothèque média pour trouver de nouveaux fichiers et rafraîchit les métadonnées.",
+    "TaskRefreshLibraryDescription": "Analyse votre médiathèque pour trouver de nouveaux fichiers et rafraîchit les métadonnées.",
     "TaskRefreshChapterImages": "Extraire les images de chapitre",
     "TaskRefreshChapterImagesDescription": "Créer des vignettes pour les vidéos qui ont des chapitres.",
-    "TaskRefreshLibrary": "Analyser la bibliothèque de médias",
+    "TaskRefreshLibrary": "Analyser la médiathèque",
     "TaskCleanCache": "Nettoyer le répertoire des fichiers temporaires",
     "TasksApplicationCategory": "Application",
     "TaskCleanCacheDescription": "Supprime les fichiers temporaires qui ne sont plus nécessaire pour le système.",
-    "TasksChannelsCategory": "Canaux Internet",
-    "Default": "Par défaut"
+    "TasksChannelsCategory": "Chaines Internet",
+    "Default": "Par défaut",
+    "TaskCleanActivityLogDescription": "Éfface les entrées du journal plus anciennes que l'âge configuré.",
+    "TaskCleanActivityLog": "Nettoyer le journal d'activité",
+    "Undefined": "Indéfini",
+    "Forced": "Forcé"
 }

+ 12 - 7
Emby.Server.Implementations/Localization/Core/lv.json

@@ -15,9 +15,9 @@
     "NotificationOptionUserLockedOut": "Lietotājs bloķēts",
     "LabelRunningTimeValue": "Garums: {0}",
     "Inherit": "Mantot",
-    "AppDeviceValues": "Lietotne:{0}, Ierīce:{1}",
+    "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}",
     "VersionNumber": "Versija {0}",
-    "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots tavai multvides bibliotēkai",
+    "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots jūsu multvides bibliotēkai",
     "UserStoppedPlayingItemWithValues": "{0} ir beidzis atskaņot {1} uz {2}",
     "UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}",
     "UserPasswordChangedWithName": "Parole nomainīta lietotājam {0}",
@@ -95,7 +95,7 @@
     "TaskRefreshChapterImages": "Izvilkt Nodaļu Attēlus",
     "TasksApplicationCategory": "Lietotne",
     "TasksLibraryCategory": "Bibliotēka",
-    "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus pēc metadatu uzstādījumiem.",
+    "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus balstoties uz metadatu uzstādījumiem.",
     "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošus subtitrus",
     "TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.",
     "TaskRefreshChannels": "Atjaunot Kanālus",
@@ -103,14 +103,19 @@
     "TaskCleanTranscode": "Iztīrīt Trans-kodēšanas Mapi",
     "TaskUpdatePluginsDescription": "Lejupielādē un uzstāda atjauninājumus paplašinājumiem, kam ir uzstādīta automātiskā atjaunināšana.",
     "TaskUpdatePlugins": "Atjaunot Paplašinājumus",
-    "TaskRefreshPeopleDescription": "Atjauno metadatus priekš aktieriem un direktoriem tavā mediju bibliotēkā.",
+    "TaskRefreshPeopleDescription": "Atjauno metadatus aktieriem un direktoriem jūsu multivides bibliotēkā.",
     "TaskRefreshPeople": "Atjaunot Cilvēkus",
     "TaskCleanLogsDescription": "Nodzēš log datnes, kas ir vairāk par {0} dienām vecas.",
     "TaskCleanLogs": "Iztīrīt Logdatņu Mapi",
-    "TaskRefreshLibraryDescription": "Skenē tavas mediju bibliotēkas priekš jaunām datnēm un atjauno metadatus.",
-    "TaskRefreshLibrary": "Skanēt Mediju Bibliotēku",
+    "TaskRefreshLibraryDescription": "Skenē jūsu multivides bibliotēku, lai atrastu jaunas datnes, un atsvaidzina metadatus.",
+    "TaskRefreshLibrary": "Skenēt Multivides Bibliotēku",
     "TaskRefreshChapterImagesDescription": "Izveido sīktēlus priekš video ar sadaļām.",
     "TaskCleanCache": "Iztīrīt Kešošanas Mapi",
     "TasksChannelsCategory": "Interneta Kanāli",
-    "TasksMaintenanceCategory": "Apkope"
+    "TasksMaintenanceCategory": "Apkope",
+    "Forced": "Piespiests",
+    "TaskCleanActivityLogDescription": "Nodzēš darbību žurnāla ierakstus, kuri ir vecāki par doto vecumu.",
+    "TaskCleanActivityLog": "Notīrīt Darbību Žurnālu",
+    "Undefined": "Nenoteikts",
+    "Default": "Noklusējums"
 }

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

@@ -113,5 +113,10 @@
     "TasksApplicationCategory": "Aplikacija",
     "TasksLibraryCategory": "Knjižnica",
     "TasksMaintenanceCategory": "Vzdrževanje",
-    "TaskDownloadMissingSubtitlesDescription": "Na podlagi nastavitev metapodatkov poišče manjkajoče podnapise na internetu."
+    "TaskDownloadMissingSubtitlesDescription": "Na podlagi nastavitev metapodatkov poišče manjkajoče podnapise na internetu.",
+    "TaskCleanActivityLogDescription": "Počisti zapise v dnevniku aktivnosti starejše od nastavljenega časa.",
+    "TaskCleanActivityLog": "Počisti dnevnik aktivnosti",
+    "Undefined": "Nedoločen",
+    "Forced": "Prisilno",
+    "Default": "Privzeto"
 }

+ 2 - 0
Emby.Server.Implementations/Properties/AssemblyInfo.cs

@@ -1,5 +1,6 @@
 using System.Reflection;
 using System.Resources;
+using System.Runtime.CompilerServices;
 using System.Runtime.InteropServices;
 
 // General Information about an assembly is controlled through the following
@@ -14,6 +15,7 @@ using System.Runtime.InteropServices;
 [assembly: AssemblyTrademark("")]
 [assembly: AssemblyCulture("")]
 [assembly: NeutralResourcesLanguage("en")]
+[assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")]
 
 // Setting ComVisible to false makes the types in this assembly not visible
 // to COM components.  If you need to access a type in this assembly from

+ 16 - 0
Emby.Server.Implementations/Session/SessionManager.cs

@@ -128,6 +128,9 @@ namespace Emby.Server.Implementations.Session
         /// <inheritdoc />
         public event EventHandler<SessionEventArgs> SessionActivity;
 
+        /// <inheritdoc />
+        public event EventHandler<SessionEventArgs> SessionControllerConnected;
+
         /// <summary>
         /// Gets all connections.
         /// </summary>
@@ -312,6 +315,19 @@ namespace Emby.Server.Implementations.Session
             return session;
         }
 
+        /// <inheritdoc />
+        public void OnSessionControllerConnected(SessionInfo info)
+        {
+            EventHelper.QueueEventIfNotNull(
+                SessionControllerConnected,
+                this,
+                new SessionEventArgs
+                {
+                    SessionInfo = info
+                },
+                _logger);
+        }
+
         /// <inheritdoc />
         public void CloseIfNeeded(SessionInfo session)
         {

+ 2 - 0
Emby.Server.Implementations/Session/SessionWebSocketListener.cs

@@ -133,6 +133,8 @@ namespace Emby.Server.Implementations.Session
 
             var controller = (WebSocketController)controllerInfo.Item1;
             controller.AddWebSocket(connection);
+
+            _sessionManager.OnSessionControllerConnected(session);
         }
 
         /// <summary>

+ 47 - 3
Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs

@@ -41,6 +41,12 @@ namespace Emby.Server.Implementations.SyncPlay
         /// </summary>
         private readonly ILibraryManager _libraryManager;
 
+        /// <summary>
+        /// The map between users and counter of active sessions.
+        /// </summary>
+        private readonly ConcurrentDictionary<Guid, int> _activeUsers =
+            new ConcurrentDictionary<Guid, int>();
+
         /// <summary>
         /// The map between sessions and groups.
         /// </summary>
@@ -81,7 +87,7 @@ namespace Emby.Server.Implementations.SyncPlay
             _sessionManager = sessionManager;
             _libraryManager = libraryManager;
             _logger = loggerFactory.CreateLogger<SyncPlayManager>();
-            _sessionManager.SessionStarted += OnSessionManagerSessionStarted;
+            _sessionManager.SessionControllerConnected += OnSessionControllerConnected;
         }
 
         /// <inheritdoc />
@@ -122,6 +128,7 @@ namespace Emby.Server.Implementations.SyncPlay
                     throw new InvalidOperationException("Could not add session to group!");
                 }
 
+                UpdateSessionsCounter(session.UserId, 1);
                 group.CreateGroup(session, request, cancellationToken);
             }
         }
@@ -172,6 +179,7 @@ namespace Emby.Server.Implementations.SyncPlay
                         if (existingGroup.GroupId.Equals(request.GroupId))
                         {
                             // Restore session.
+                            UpdateSessionsCounter(session.UserId, 1);
                             group.SessionJoin(session, request, cancellationToken);
                             return;
                         }
@@ -185,6 +193,7 @@ namespace Emby.Server.Implementations.SyncPlay
                         throw new InvalidOperationException("Could not add session to group!");
                     }
 
+                    UpdateSessionsCounter(session.UserId, 1);
                     group.SessionJoin(session, request, cancellationToken);
                 }
             }
@@ -223,6 +232,7 @@ namespace Emby.Server.Implementations.SyncPlay
                             throw new InvalidOperationException("Could not remove session from group!");
                         }
 
+                        UpdateSessionsCounter(session.UserId, -1);
                         group.SessionLeave(session, request, cancellationToken);
 
                         if (group.IsGroupEmpty())
@@ -318,6 +328,19 @@ namespace Emby.Server.Implementations.SyncPlay
             }
         }
 
+        /// <inheritdoc />
+        public bool IsUserActive(Guid userId)
+        {
+            if (_activeUsers.TryGetValue(userId, out var sessionsCounter))
+            {
+                return sessionsCounter > 0;
+            }
+            else
+            {
+                return false;
+            }
+        }
+
         /// <summary>
         /// Releases unmanaged and optionally managed resources.
         /// </summary>
@@ -329,11 +352,11 @@ namespace Emby.Server.Implementations.SyncPlay
                 return;
             }
 
-            _sessionManager.SessionStarted -= OnSessionManagerSessionStarted;
+            _sessionManager.SessionControllerConnected -= OnSessionControllerConnected;
             _disposed = true;
         }
 
-        private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e)
+        private void OnSessionControllerConnected(object sender, SessionEventArgs e)
         {
             var session = e.SessionInfo;
 
@@ -343,5 +366,26 @@ namespace Emby.Server.Implementations.SyncPlay
                 JoinGroup(session, request, CancellationToken.None);
             }
         }
+
+        private void UpdateSessionsCounter(Guid userId, int toAdd)
+        {
+            // Update sessions counter.
+            var newSessionsCounter = _activeUsers.AddOrUpdate(
+                userId,
+                1,
+                (key, sessionsCounter) => sessionsCounter + toAdd);
+
+            // Should never happen.
+            if (newSessionsCounter < 0)
+            {
+                throw new InvalidOperationException("Sessions counter is negative!");
+            }
+
+            // Clean record if user has no more active sessions.
+            if (newSessionsCounter == 0)
+            {
+                _activeUsers.TryRemove(new KeyValuePair<Guid, int>(userId, newSessionsCounter));
+            }
+        }
     }
 }

+ 2 - 1
Emby.Server.Implementations/Updates/InstallationManager.cs

@@ -231,7 +231,7 @@ namespace Emby.Server.Implementations.Updates
                         }
 
                         // Don't add a package that doesn't have any compatible versions.
-                        if (package.Versions.Count == 0)
+                        if (package.versions.Count == 0)
                         {
                             continue;
                         }
@@ -555,6 +555,7 @@ namespace Emby.Server.Implementations.Updates
 
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
                 .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
+            response.EnsureSuccessStatusCode();
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 
             // CA5351: Do Not Use Broken Cryptographic Algorithms

+ 50 - 3
Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs

@@ -3,6 +3,7 @@ using Jellyfin.Api.Helpers;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.SyncPlay;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 
@@ -13,20 +14,24 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
     /// </summary>
     public class SyncPlayAccessHandler : BaseAuthorizationHandler<SyncPlayAccessRequirement>
     {
+        private readonly ISyncPlayManager _syncPlayManager;
         private readonly IUserManager _userManager;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="SyncPlayAccessHandler"/> class.
         /// </summary>
+        /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param>
         /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
         /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
         /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
         public SyncPlayAccessHandler(
+            ISyncPlayManager syncPlayManager,
             IUserManager userManager,
             INetworkManager networkManager,
             IHttpContextAccessor httpContextAccessor)
             : base(userManager, networkManager, httpContextAccessor)
         {
+            _syncPlayManager = syncPlayManager;
             _userManager = userManager;
         }
 
@@ -42,10 +47,52 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
             var userId = ClaimHelpers.GetUserId(context.User);
             var user = _userManager.GetUserById(userId!.Value);
 
-            if ((requirement.RequiredAccess.HasValue && user.SyncPlayAccess == requirement.RequiredAccess)
-                || user.SyncPlayAccess == SyncPlayAccess.CreateAndJoinGroups)
+            if (requirement.RequiredAccess == SyncPlayAccessRequirementType.HasAccess)
             {
-                context.Succeed(requirement);
+                if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups
+                    || user.SyncPlayAccess == SyncPlayUserAccessType.JoinGroups
+                    || _syncPlayManager.IsUserActive(userId!.Value))
+                {
+                    context.Succeed(requirement);
+                }
+                else
+                {
+                    context.Fail();
+                }
+            }
+            else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.CreateGroup)
+            {
+                if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups)
+                {
+                    context.Succeed(requirement);
+                }
+                else
+                {
+                    context.Fail();
+                }
+            }
+            else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.JoinGroup)
+            {
+                if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups
+                    || user.SyncPlayAccess == SyncPlayUserAccessType.JoinGroups)
+                {
+                    context.Succeed(requirement);
+                }
+                else
+                {
+                    context.Fail();
+                }
+            }
+            else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.IsInGroup)
+            {
+                if (_syncPlayManager.IsUserActive(userId!.Value))
+                {
+                    context.Succeed(requirement);
+                }
+                else
+                {
+                    context.Fail();
+                }
             }
             else
             {

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

@@ -11,23 +11,15 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
         /// <summary>
         /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
         /// </summary>
-        /// <param name="requiredAccess">A value of <see cref="SyncPlayAccess"/>.</param>
-        public SyncPlayAccessRequirement(SyncPlayAccess requiredAccess)
+        /// <param name="requiredAccess">A value of <see cref="SyncPlayAccessRequirementType"/>.</param>
+        public SyncPlayAccessRequirement(SyncPlayAccessRequirementType requiredAccess)
         {
             RequiredAccess = requiredAccess;
         }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
-        /// </summary>
-        public SyncPlayAccessRequirement()
-        {
-            RequiredAccess = null;
-        }
-
         /// <summary>
         /// Gets the required SyncPlay access.
         /// </summary>
-        public SyncPlayAccess? RequiredAccess { get; }
+        public SyncPlayAccessRequirementType RequiredAccess { get; }
     }
 }

+ 14 - 4
Jellyfin.Api/Constants/Policies.cs

@@ -51,13 +51,23 @@ namespace Jellyfin.Api.Constants
         public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl";
 
         /// <summary>
-        /// Policy name for requiring access to SyncPlay.
+        /// Policy name for accessing SyncPlay.
         /// </summary>
-        public const string SyncPlayAccess = "SyncPlayAccess";
+        public const string SyncPlayHasAccess = "SyncPlayHasAccess";
 
         /// <summary>
-        /// Policy name for requiring group creation access to SyncPlay.
+        /// Policy name for creating a SyncPlay group.
         /// </summary>
-        public const string SyncPlayCreateGroupAccess = "SyncPlayCreateGroupAccess";
+        public const string SyncPlayCreateGroup = "SyncPlayCreateGroup";
+
+        /// <summary>
+        /// Policy name for joining a SyncPlay group.
+        /// </summary>
+        public const string SyncPlayJoinGroup = "SyncPlayJoinGroup";
+
+        /// <summary>
+        /// Policy name for accessing a SyncPlay group.
+        /// </summary>
+        public const string SyncPlayIsInGroup = "SyncPlayIsInGroup";
     }
 }

+ 7 - 15
Jellyfin.Api/Controllers/DisplayPreferencesController.cs

@@ -12,6 +12,7 @@ using MediaBrowser.Model.Entities;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
 
 namespace Jellyfin.Api.Controllers
 {
@@ -22,14 +23,17 @@ namespace Jellyfin.Api.Controllers
     public class DisplayPreferencesController : BaseJellyfinApiController
     {
         private readonly IDisplayPreferencesManager _displayPreferencesManager;
+        private readonly ILogger<DisplayPreferencesController> _logger;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class.
         /// </summary>
         /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param>
-        public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager)
+        /// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param>
+        public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger)
         {
             _displayPreferencesManager = displayPreferencesManager;
+            _logger = logger;
         }
 
         /// <summary>
@@ -61,7 +65,6 @@ namespace Jellyfin.Api.Controllers
             {
                 Client = displayPreferences.Client,
                 Id = displayPreferences.ItemId.ToString(),
-                ViewType = itemPreferences.ViewType.ToString(),
                 SortBy = itemPreferences.SortBy,
                 SortOrder = itemPreferences.SortOrder,
                 IndexBy = displayPreferences.IndexBy?.ToString(),
@@ -77,11 +80,6 @@ namespace Jellyfin.Api.Controllers
                 dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant();
             }
 
-            foreach (var itemDisplayPreferences in _displayPreferencesManager.ListItemDisplayPreferences(displayPreferences.UserId, displayPreferences.Client))
-            {
-                dto.CustomPrefs["landing-" + itemDisplayPreferences.ItemId] = itemDisplayPreferences.ViewType.ToString().ToLowerInvariant();
-            }
-
             dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant();
             dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture);
             dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture);
@@ -189,10 +187,9 @@ namespace Jellyfin.Api.Controllers
 
             foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
             {
-                if (Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var preferenceId))
+                if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type))
                 {
-                    var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, preferenceId, existingDisplayPreferences.Client);
-                    itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType);
+                    _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]);
                     displayPreferences.CustomPrefs.Remove(key);
                 }
             }
@@ -204,11 +201,6 @@ namespace Jellyfin.Api.Controllers
             itemPrefs.RememberSorting = displayPreferences.RememberSorting;
             itemPrefs.ItemId = itemId;
 
-            if (Enum.TryParse<ViewType>(displayPreferences.ViewType, true, out var viewType))
-            {
-                itemPrefs.ViewType = viewType;
-            }
-
             // Set all remaining custom preferences.
             _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
             _displayPreferencesManager.SaveChanges();

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

@@ -98,7 +98,7 @@ namespace Jellyfin.Api.Controllers
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             {
-                return Forbid("User is not allowed to update the image.");
+                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
             }
 
             var user = _userManager.GetUserById(userId);
@@ -144,7 +144,7 @@ namespace Jellyfin.Api.Controllers
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             {
-                return Forbid("User is not allowed to update the image.");
+                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
             }
 
             var user = _userManager.GetUserById(userId);
@@ -190,7 +190,7 @@ namespace Jellyfin.Api.Controllers
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             {
-                return Forbid("User is not allowed to delete the image.");
+                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
             }
 
             var user = _userManager.GetUserById(userId);
@@ -229,7 +229,7 @@ namespace Jellyfin.Api.Controllers
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             {
-                return Forbid("User is not allowed to delete the image.");
+                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
             }
 
             var user = _userManager.GetUserById(userId);

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

@@ -17,6 +17,7 @@ using MediaBrowser.Model.MediaInfo;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
 using Microsoft.Extensions.Logging;
 
 namespace Jellyfin.Api.Controllers
@@ -119,7 +120,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableTranscoding,
             [FromQuery] bool? allowVideoStreamCopy,
             [FromQuery] bool? allowAudioStreamCopy,
-            [FromBody] PlaybackInfoDto? playbackInfoDto)
+            [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] PlaybackInfoDto? playbackInfoDto)
         {
             var authInfo = _authContext.GetAuthorizationInfo(Request);
 

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

@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Threading.Tasks;
@@ -17,6 +18,7 @@ using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
 
 namespace Jellyfin.Api.Controllers
 {
@@ -53,6 +55,13 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Creates a new playlist.
         /// </summary>
+        /// <remarks>
+        /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.
+        /// </remarks>
+        /// <param name="name">The playlist name.</param>
+        /// <param name="ids">The item ids.</param>
+        /// <param name="userId">The user id.</param>
+        /// <param name="mediaType">The media type.</param>
         /// <param name="createPlaylistRequest">The create playlist payload.</param>
         /// <returns>
         /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist.
@@ -61,14 +70,23 @@ namespace Jellyfin.Api.Controllers
         [HttpPost]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
-            [FromBody, Required] CreatePlaylistDto createPlaylistRequest)
+            [FromQuery] string? name,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] IReadOnlyList<Guid> ids,
+            [FromQuery] Guid? userId,
+            [FromQuery] string? mediaType,
+            [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest)
         {
+            if (ids.Count == 0)
+            {
+                ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>();
+            }
+
             var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
             {
-                Name = createPlaylistRequest.Name,
-                ItemIdList = createPlaylistRequest.Ids,
-                UserId = createPlaylistRequest.UserId,
-                MediaType = createPlaylistRequest.MediaType
+                Name = name ?? createPlaylistRequest?.Name,
+                ItemIdList = ids,
+                UserId = userId ?? createPlaylistRequest?.UserId ?? default,
+                MediaType = mediaType ?? createPlaylistRequest?.MediaType
             }).ConfigureAwait(false);
 
             return result;

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

@@ -88,7 +88,7 @@ namespace Jellyfin.Api.Controllers
         {
             if (_quickConnect.State == QuickConnectState.Unavailable)
             {
-                return Forbid("Quick connect is unavailable");
+                return StatusCode(StatusCodes.Status403Forbidden, "Quick connect is unavailable");
             }
 
             _quickConnect.Activate();
@@ -126,7 +126,7 @@ namespace Jellyfin.Api.Controllers
             var userId = ClaimHelpers.GetUserId(Request.HttpContext.User);
             if (!userId.HasValue)
             {
-                return Forbid("Unknown user id");
+                return StatusCode(StatusCodes.Status403Forbidden, "Unknown user id");
             }
 
             return _quickConnect.AuthorizeRequest(userId.Value, code);

+ 21 - 4
Jellyfin.Api/Controllers/SyncPlayController.cs

@@ -20,7 +20,7 @@ namespace Jellyfin.Api.Controllers
     /// <summary>
     /// The sync play controller.
     /// </summary>
-    [Authorize(Policy = Policies.SyncPlayAccess)]
+    [Authorize(Policy = Policies.SyncPlayHasAccess)]
     public class SyncPlayController : BaseJellyfinApiController
     {
         private readonly ISessionManager _sessionManager;
@@ -51,7 +51,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("New")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayCreateGroupAccess)]
+        [Authorize(Policy = Policies.SyncPlayCreateGroup)]
         public ActionResult SyncPlayCreateGroup(
             [FromBody, Required] NewGroupRequestDto requestData)
         {
@@ -69,7 +69,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Join")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        [Authorize(Policy = Policies.SyncPlayAccess)]
+        [Authorize(Policy = Policies.SyncPlayJoinGroup)]
         public ActionResult SyncPlayJoinGroup(
             [FromBody, Required] JoinGroupRequestDto requestData)
         {
@@ -86,6 +86,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Leave")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlayLeaveGroup()
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
@@ -101,7 +102,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns>
         [HttpGet("List")]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        [Authorize(Policy = Policies.SyncPlayAccess)]
+        [Authorize(Policy = Policies.SyncPlayJoinGroup)]
         public ActionResult<IEnumerable<GroupInfoDto>> SyncPlayGetGroups()
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
@@ -117,6 +118,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("SetNewQueue")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlaySetNewQueue(
             [FromBody, Required] PlayRequestDto requestData)
         {
@@ -137,6 +139,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("SetPlaylistItem")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlaySetPlaylistItem(
             [FromBody, Required] SetPlaylistItemRequestDto requestData)
         {
@@ -154,6 +157,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("RemoveFromPlaylist")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlayRemoveFromPlaylist(
             [FromBody, Required] RemoveFromPlaylistRequestDto requestData)
         {
@@ -171,6 +175,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("MovePlaylistItem")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlayMovePlaylistItem(
             [FromBody, Required] MovePlaylistItemRequestDto requestData)
         {
@@ -188,6 +193,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Queue")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlayQueue(
             [FromBody, Required] QueueRequestDto requestData)
         {
@@ -204,6 +210,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Unpause")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlayUnpause()
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
@@ -219,6 +226,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Pause")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlayPause()
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
@@ -234,6 +242,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Stop")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlayStop()
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
@@ -250,6 +259,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Seek")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlaySeek(
             [FromBody, Required] SeekRequestDto requestData)
         {
@@ -267,6 +277,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Buffering")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlayBuffering(
             [FromBody, Required] BufferRequestDto requestData)
         {
@@ -288,6 +299,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Ready")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlayReady(
             [FromBody, Required] ReadyRequestDto requestData)
         {
@@ -309,6 +321,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("SetIgnoreWait")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlaySetIgnoreWait(
             [FromBody, Required] IgnoreWaitRequestDto requestData)
         {
@@ -326,6 +339,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("NextItem")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlayNextItem(
             [FromBody, Required] NextItemRequestDto requestData)
         {
@@ -343,6 +357,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("PreviousItem")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlayPreviousItem(
             [FromBody, Required] PreviousItemRequestDto requestData)
         {
@@ -360,6 +375,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("SetRepeatMode")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlaySetRepeatMode(
             [FromBody, Required] SetRepeatModeRequestDto requestData)
         {
@@ -377,6 +393,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("SetShuffleMode")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [Authorize(Policy = Policies.SyncPlayIsInGroup)]
         public ActionResult SyncPlaySetShuffleMode(
             [FromBody, Required] SetShuffleModeRequestDto requestData)
         {

+ 17 - 27
Jellyfin.Api/Controllers/UserController.cs

@@ -133,11 +133,11 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult DeleteUser([FromRoute, Required] Guid userId)
+        public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId)
         {
             var user = _userManager.GetUserById(userId);
             _sessionManager.RevokeUserTokens(user.Id, null);
-            _userManager.DeleteUser(userId);
+            await _userManager.DeleteUserAsync(userId).ConfigureAwait(false);
             return NoContent();
         }
 
@@ -169,7 +169,7 @@ namespace Jellyfin.Api.Controllers
 
             if (!string.IsNullOrEmpty(password) && string.IsNullOrEmpty(pw))
             {
-                return Forbid("Only sha1 password is not allowed.");
+                return StatusCode(StatusCodes.Status403Forbidden, "Only sha1 password is not allowed.");
             }
 
             // Password should always be null
@@ -267,11 +267,11 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult> UpdateUserPassword(
             [FromRoute, Required] Guid userId,
-            [FromBody] UpdateUserPassword request)
+            [FromBody, Required] UpdateUserPassword request)
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             {
-                return Forbid("User is not allowed to update the password.");
+                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password.");
             }
 
             var user = _userManager.GetUserById(userId);
@@ -296,7 +296,7 @@ namespace Jellyfin.Api.Controllers
 
                 if (success == null)
                 {
-                    return Forbid("Invalid user or password entered.");
+                    return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered.");
                 }
 
                 await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false);
@@ -325,11 +325,11 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult UpdateUserEasyPassword(
             [FromRoute, Required] Guid userId,
-            [FromBody] UpdateUserEasyPassword request)
+            [FromBody, Required] UpdateUserEasyPassword request)
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             {
-                return Forbid("User is not allowed to update the easy password.");
+                return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the easy password.");
             }
 
             var user = _userManager.GetUserById(userId);
@@ -367,16 +367,11 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
         public async Task<ActionResult> UpdateUser(
             [FromRoute, Required] Guid userId,
-            [FromBody] UserDto updateUser)
+            [FromBody, Required] UserDto updateUser)
         {
-            if (updateUser == null)
-            {
-                return BadRequest();
-            }
-
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false))
             {
-                return Forbid("User update not allowed.");
+                return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed.");
             }
 
             var user = _userManager.GetUserById(userId);
@@ -407,13 +402,8 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
         public async Task<ActionResult> UpdateUserPolicy(
             [FromRoute, Required] Guid userId,
-            [FromBody] UserPolicy newPolicy)
+            [FromBody, Required] UserPolicy newPolicy)
         {
-            if (newPolicy == null)
-            {
-                return BadRequest();
-            }
-
             var user = _userManager.GetUserById(userId);
 
             // If removing admin access
@@ -421,14 +411,14 @@ namespace Jellyfin.Api.Controllers
             {
                 if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
                 {
-                    return Forbid("There must be at least one user in the system with administrative access.");
+                    return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access.");
                 }
             }
 
             // If disabling
             if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator))
             {
-                return Forbid("Administrators cannot be disabled.");
+                return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled.");
             }
 
             // If disabling
@@ -436,7 +426,7 @@ namespace Jellyfin.Api.Controllers
             {
                 if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
                 {
-                    return Forbid("There must be at least one enabled user in the system.");
+                    return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system.");
                 }
 
                 var currentToken = _authContext.GetAuthorizationInfo(Request).Token;
@@ -462,11 +452,11 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
         public async Task<ActionResult> UpdateUserConfiguration(
             [FromRoute, Required] Guid userId,
-            [FromBody] UserConfiguration userConfig)
+            [FromBody, Required] UserConfiguration userConfig)
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false))
             {
-                return Forbid("User configuration update not allowed");
+                return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed");
             }
 
             await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false);
@@ -483,7 +473,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("New")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<UserDto>> CreateUserByName([FromBody] CreateUserByName request)
+        public async Task<ActionResult<UserDto>> CreateUserByName([FromBody, Required] CreateUserByName request)
         {
             var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false);
 

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

@@ -196,7 +196,7 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Merges videos into a single record.
         /// </summary>
-        /// <param name="itemIds">Item id list. This allows multiple, comma delimited.</param>
+        /// <param name="ids">Item id list. This allows multiple, comma delimited.</param>
         /// <response code="204">Videos merged.</response>
         /// <response code="400">Supply at least 2 video ids.</response>
         /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns>
@@ -204,9 +204,9 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status400BadRequest)]
-        public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds)
+        public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
         {
-            var items = itemIds
+            var items = ids
                 .Select(i => _libraryManager.GetItemById(i))
                 .OfType<Video>()
                 .OrderBy(i => i.Id)

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

@@ -16,7 +16,7 @@
 
   <ItemGroup>
     <PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
-    <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="5.0.0" />
+    <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="5.0.1" />
     <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
     <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
     <PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />

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

@@ -24,7 +24,7 @@ namespace Jellyfin.Api.Models.PlaylistDtos
         /// <summary>
         /// Gets or sets the user id.
         /// </summary>
-        public Guid UserId { get; set; }
+        public Guid? UserId { get; set; }
 
         /// <summary>
         /// Gets or sets the media type.

+ 3 - 2
Jellyfin.Data/Entities/ActivityLog.cs

@@ -18,7 +18,8 @@ namespace Jellyfin.Data.Entities
         /// <param name="name">The name.</param>
         /// <param name="type">The type.</param>
         /// <param name="userId">The user id.</param>
-        public ActivityLog(string name, string type, Guid userId)
+        /// <param name="logLevel">The log level.</param>
+        public ActivityLog(string name, string type, Guid userId, LogLevel logLevel = LogLevel.Information)
         {
             if (string.IsNullOrEmpty(name))
             {
@@ -34,7 +35,7 @@ namespace Jellyfin.Data.Entities
             Type = type;
             UserId = userId;
             DateCreated = DateTime.UtcNow;
-            LogSeverity = LogLevel.Trace;
+            LogSeverity = logLevel;
         }
 
         /// <summary>

+ 0 - 1
Jellyfin.Data/Entities/ItemDisplayPreferences.cs

@@ -23,7 +23,6 @@ namespace Jellyfin.Data.Entities
             Client = client;
 
             SortBy = "SortName";
-            ViewType = ViewType.Poster;
             SortOrder = SortOrder.Ascending;
             RememberSorting = false;
             RememberIndexing = false;

+ 2 - 2
Jellyfin.Data/Entities/User.cs

@@ -71,7 +71,7 @@ namespace Jellyfin.Data.Entities
             EnableAutoLogin = false;
             PlayDefaultAudioTrack = true;
             SubtitleMode = SubtitlePlaybackMode.Default;
-            SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups;
+            SyncPlayAccess = SyncPlayUserAccessType.CreateAndJoinGroups;
 
             AddDefaultPermissions();
             AddDefaultPreferences();
@@ -326,7 +326,7 @@ namespace Jellyfin.Data.Entities
         /// <summary>
         /// Gets or sets the level of sync play permissions this user has.
         /// </summary>
-        public SyncPlayAccess SyncPlayAccess { get; set; }
+        public SyncPlayUserAccessType SyncPlayAccess { get; set; }
 
         /// <summary>
         /// Gets or sets the row version.

+ 28 - 0
Jellyfin.Data/Enums/SyncPlayAccessRequirementType.cs

@@ -0,0 +1,28 @@
+namespace Jellyfin.Data.Enums
+{
+    /// <summary>
+    /// Enum SyncPlayAccessRequirementType.
+    /// </summary>
+    public enum SyncPlayAccessRequirementType
+    {
+        /// <summary>
+        /// User must have access to SyncPlay, in some form.
+        /// </summary>
+        HasAccess = 0,
+
+        /// <summary>
+        /// User must be able to create groups.
+        /// </summary>
+        CreateGroup = 1,
+
+        /// <summary>
+        /// User must be able to join groups.
+        /// </summary>
+        JoinGroup = 2,
+
+        /// <summary>
+        /// User must be in a group.
+        /// </summary>
+        IsInGroup = 3
+    }
+}

+ 2 - 2
Jellyfin.Data/Enums/SyncPlayAccess.cs → Jellyfin.Data/Enums/SyncPlayUserAccessType.cs

@@ -1,9 +1,9 @@
 namespace Jellyfin.Data.Enums
 {
     /// <summary>
-    /// Enum SyncPlayAccess.
+    /// Enum SyncPlayUserAccessType.
     /// </summary>
-    public enum SyncPlayAccess
+    public enum SyncPlayUserAccessType
     {
         /// <summary>
         /// User can create groups and join them.

+ 88 - 13
Jellyfin.Data/Enums/ViewType.cs

@@ -1,4 +1,4 @@
-namespace Jellyfin.Data.Enums
+namespace Jellyfin.Data.Enums
 {
     /// <summary>
     /// An enum representing the type of view for a library or collection.
@@ -6,33 +6,108 @@
     public enum ViewType
     {
         /// <summary>
-        /// Shows banners.
+        /// Shows albums.
         /// </summary>
-        Banner = 0,
+        Albums = 0,
 
         /// <summary>
-        /// Shows a list of content.
+        /// Shows album artists.
         /// </summary>
-        List = 1,
+        AlbumArtists = 1,
 
         /// <summary>
-        /// Shows poster artwork.
+        /// Shows artists.
         /// </summary>
-        Poster = 2,
+        Artists = 2,
 
         /// <summary>
-        /// Shows poster artwork with a card containing the name and year.
+        /// Shows channels.
         /// </summary>
-        PosterCard = 3,
+        Channels = 3,
 
         /// <summary>
-        /// Shows a thumbnail.
+        /// Shows collections.
         /// </summary>
-        Thumb = 4,
+        Collections = 4,
 
         /// <summary>
-        /// Shows a thumbnail with a card containing the name and year.
+        /// Shows episodes.
         /// </summary>
-        ThumbCard = 5
+        Episodes = 5,
+
+        /// <summary>
+        /// Shows favorites.
+        /// </summary>
+        Favorites = 6,
+
+        /// <summary>
+        /// Shows genres.
+        /// </summary>
+        Genres = 7,
+
+        /// <summary>
+        /// Shows guide.
+        /// </summary>
+        Guide = 8,
+
+        /// <summary>
+        /// Shows movies.
+        /// </summary>
+        Movies = 9,
+
+        /// <summary>
+        /// Shows networks.
+        /// </summary>
+        Networks = 10,
+
+        /// <summary>
+        /// Shows playlists.
+        /// </summary>
+        Playlists = 11,
+
+        /// <summary>
+        /// Shows programs.
+        /// </summary>
+        Programs = 12,
+
+        /// <summary>
+        /// Shows recordings.
+        /// </summary>
+        Recordings = 13,
+
+        /// <summary>
+        /// Shows schedule.
+        /// </summary>
+        Schedule = 14,
+
+        /// <summary>
+        /// Shows series.
+        /// </summary>
+        Series = 15,
+
+        /// <summary>
+        /// Shows shows.
+        /// </summary>
+        Shows = 16,
+
+        /// <summary>
+        /// Shows songs.
+        /// </summary>
+        Songs = 17,
+
+        /// <summary>
+        /// Shows songs.
+        /// </summary>
+        Suggestions = 18,
+
+        /// <summary>
+        /// Shows trailers.
+        /// </summary>
+        Trailers = 19,
+
+        /// <summary>
+        /// Shows upcoming.
+        /// </summary>
+        Upcoming = 20
     }
 }

+ 2 - 2
Jellyfin.Data/Jellyfin.Data.csproj

@@ -41,8 +41,8 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.0" />
-    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.0" />
+    <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.1" />
+    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.1" />
   </ItemGroup>
 
   <ItemGroup>

+ 1 - 1
Jellyfin.Drawing.Skia/SkiaEncoder.cs

@@ -435,7 +435,7 @@ namespace Jellyfin.Drawing.Skia
                 0f,
                 kernelOffset,
                 SKShaderTileMode.Clamp,
-                false);
+                true);
 
             canvas.DrawBitmap(
                 source,

+ 11 - 1
Jellyfin.Networking/Configuration/NetworkConfiguration.cs

@@ -27,6 +27,16 @@ namespace Jellyfin.Networking.Configuration
         /// </summary>
         public bool RequireHttps { get; set; }
 
+        /// <summary>
+        /// Gets or sets the filesystem path of an X.509 certificate to use for SSL.
+        /// </summary>
+        public string CertificatePath { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the password required to access the X.509 certificate data in the file specified by <see cref="CertificatePath"/>.
+        /// </summary>
+        public string CertificatePassword { get; set; } = string.Empty;
+
         /// <summary>
         /// Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at.
         /// </summary>
@@ -83,7 +93,7 @@ namespace Jellyfin.Networking.Configuration
         /// </summary>
         /// <remarks>
         /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be
-        /// provided for <see cref="ServerConfiguration.CertificatePath"/> and <see cref="ServerConfiguration.CertificatePassword"/>.
+        /// provided for <see cref="CertificatePath"/> and <see cref="CertificatePassword"/>.
         /// </remarks>
         public bool EnableHttps { get; set; }
 

+ 1 - 3
Jellyfin.Networking/Manager/NetworkManager.cs

@@ -1314,9 +1314,7 @@ namespace Jellyfin.Networking.Manager
                 return true;
             }
 
-            // Have to return something, so return an internal address
-
-            _logger.LogWarning("{Source}: External request received, however, no WAN interface found.", source);
+            _logger.LogDebug("{Source}: External request received, but no WAN interface found. Need to route through internal network.", source);
             return false;
         }
     }

+ 1 - 1
Jellyfin.Server.Implementations/Activity/ActivityManager.cs

@@ -27,7 +27,7 @@ namespace Jellyfin.Server.Implementations.Activity
         }
 
         /// <inheritdoc/>
-        public event EventHandler<GenericEventArgs<ActivityLogEntry>> EntryCreated;
+        public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated;
 
         /// <inheritdoc/>
         public async Task CreateAsync(ActivityLog entry)

+ 1 - 1
Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs

@@ -86,7 +86,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
             return name;
         }
 
-        private static string GetPlaybackNotificationType(string mediaType)
+        private static string? GetPlaybackNotificationType(string mediaType)
         {
             if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
             {

+ 1 - 1
Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs

@@ -94,7 +94,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
             return name;
         }
 
-        private static string GetPlaybackStoppedNotificationType(string mediaType)
+        private static string? GetPlaybackStoppedNotificationType(string mediaType)
         {
             if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
             {

+ 3 - 2
Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj

@@ -5,6 +5,7 @@
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+    <Nullable>enable</Nullable>
   </PropertyGroup>
 
   <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
@@ -25,11 +26,11 @@
 
   <ItemGroup>
     <PackageReference Include="System.Linq.Async" Version="5.0.0" />
-    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.0">
+    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.1">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>
-    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.0">
+    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.1">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>

+ 1 - 0
Jellyfin.Server.Implementations/JellyfinDb.cs

@@ -1,3 +1,4 @@
+#nullable disable
 #pragma warning disable CS1591
 
 using System;

+ 0 - 2
Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs

@@ -1,5 +1,3 @@
-#nullable enable
-
 using System;
 using System.Linq;
 using System.Text;

+ 0 - 2
Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs

@@ -1,5 +1,3 @@
-#nullable enable
-
 using System;
 using System.Collections.Generic;
 using System.IO;

+ 1 - 2
Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs

@@ -1,5 +1,4 @@
-#nullable enable
-#pragma warning disable CS1591
+#pragma warning disable CS1591
 
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;

+ 0 - 2
Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs

@@ -1,5 +1,3 @@
-#nullable enable
-
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Authentication;

+ 5 - 6
Jellyfin.Server.Implementations/Users/UserManager.cs

@@ -1,5 +1,4 @@
-#nullable enable
-#pragma warning disable CA1307
+#pragma warning disable CA1307
 
 using System;
 using System.Collections.Concurrent;
@@ -220,7 +219,7 @@ namespace Jellyfin.Server.Implementations.Users
         }
 
         /// <inheritdoc/>
-        public void DeleteUser(Guid userId)
+        public async Task DeleteUserAsync(Guid userId)
         {
             if (!_users.TryGetValue(userId, out var user))
             {
@@ -246,7 +245,7 @@ namespace Jellyfin.Server.Implementations.Users
                     nameof(userId));
             }
 
-            using var dbContext = _dbProvider.CreateContext();
+            await using var dbContext = _dbProvider.CreateContext();
 
             // Clear all entities related to the user from the database.
             if (user.ProfileImage != null)
@@ -258,10 +257,10 @@ namespace Jellyfin.Server.Implementations.Users
             dbContext.RemoveRange(user.Preferences);
             dbContext.RemoveRange(user.AccessSchedules);
             dbContext.Users.Remove(user);
-            dbContext.SaveChanges();
+            await dbContext.SaveChangesAsync().ConfigureAwait(false);
             _users.Remove(userId);
 
-            _eventManager.Publish(new UserDeletedEventArgs(user));
+            await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
         }
 
         /// <inheritdoc/>

+ 2 - 2
Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs

@@ -13,9 +13,9 @@ namespace Jellyfin.Server.Implementations.ValueConverters
         /// </summary>
         /// <param name="kind">The kind to specify.</param>
         /// <param name="mappingHints">The mapping hints.</param>
-        public DateTimeKindValueConverter(DateTimeKind kind, ConverterMappingHints mappingHints = null)
+        public DateTimeKindValueConverter(DateTimeKind kind, ConverterMappingHints? mappingHints = null)
             : base(v => v.ToUniversalTime(), v => DateTime.SpecifyKind(v, kind), mappingHints)
         {
         }
     }
-}
+}

+ 23 - 0
Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs

@@ -107,5 +107,28 @@ namespace Jellyfin.Server.Extensions
         {
             return appBuilder.UseMiddleware<WebSocketHandlerMiddleware>();
         }
+
+        /// <summary>
+        /// Adds robots.txt redirection to the application pipeline.
+        /// </summary>
+        /// <param name="appBuilder">The application builder.</param>
+        /// <returns>The updated application builder.</returns>
+        public static IApplicationBuilder UseRobotsRedirection(this IApplicationBuilder appBuilder)
+        {
+            return appBuilder.UseMiddleware<RobotsRedirectionMiddleware>();
+        }
+
+        /// <summary>
+        /// Adds /emby and /mediabrowser route trimming to the application pipeline.
+        /// </summary>
+        /// <remarks>
+        /// This must be injected before any path related middleware.
+        /// </remarks>
+        /// <param name="appBuilder">The application builder.</param>
+        /// <returns>The updated application builder.</returns>
+        public static IApplicationBuilder UsePathTrim(this IApplicationBuilder appBuilder)
+        {
+            return appBuilder.UseMiddleware<LegacyEmbyRouteRewriteMiddleware>();
+        }
     }
 }

+ 30 - 7
Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs

@@ -24,6 +24,7 @@ using Jellyfin.Server.Configuration;
 using Jellyfin.Server.Filters;
 using Jellyfin.Server.Formatters;
 using MediaBrowser.Common.Json;
+using MediaBrowser.Common.Net;
 using MediaBrowser.Model.Entities;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authorization;
@@ -127,18 +128,32 @@ namespace Jellyfin.Server.Extensions
                         policy.AddRequirements(new RequiresElevationRequirement());
                     });
                 options.AddPolicy(
-                    Policies.SyncPlayAccess,
+                    Policies.SyncPlayHasAccess,
                     policy =>
                     {
                         policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
-                        policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.JoinGroups));
+                        policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.HasAccess));
                     });
                 options.AddPolicy(
-                    Policies.SyncPlayCreateGroupAccess,
+                    Policies.SyncPlayCreateGroup,
                     policy =>
                     {
                         policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
-                        policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.CreateAndJoinGroups));
+                        policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.CreateGroup));
+                    });
+                options.AddPolicy(
+                    Policies.SyncPlayJoinGroup,
+                    policy =>
+                    {
+                        policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+                        policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.JoinGroup));
+                    });
+                options.AddPolicy(
+                    Policies.SyncPlayIsInGroup,
+                    policy =>
+                    {
+                        policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+                        policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.IsInGroup));
                     });
             });
         }
@@ -169,11 +184,19 @@ namespace Jellyfin.Server.Extensions
                 .Configure<ForwardedHeadersOptions>(options =>
                 {
                     options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
-                    for (var i = 0; i < knownProxies.Count; i++)
+                    if (knownProxies.Count == 0)
                     {
-                        if (IPAddress.TryParse(knownProxies[i], out var address))
+                        options.KnownNetworks.Clear();
+                        options.KnownProxies.Clear();
+                    }
+                    else
+                    {
+                        for (var i = 0; i < knownProxies.Count; i++)
                         {
-                            options.KnownProxies.Add(address);
+                            if (IPHost.TryParse(knownProxies[i], out var host))
+                            {
+                                options.KnownProxies.Add(host.Address);
+                            }
                         }
                     }
                 })

+ 2 - 1
Jellyfin.Server/Filters/FileResponseFilter.cs

@@ -14,7 +14,8 @@ namespace Jellyfin.Server.Filters
         {
             Schema = new OpenApiSchema
             {
-                Type = "file"
+                Type = "string",
+                Format = "binary"
             }
         };
 

+ 2 - 2
Jellyfin.Server/Jellyfin.Server.csproj

@@ -40,8 +40,8 @@
     <PackageReference Include="CommandLineParser" Version="2.8.0" />
     <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="5.0.0" />
     <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
-    <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="5.0.0" />
-    <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="5.0.0" />
+    <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="5.0.1" />
+    <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="5.0.1" />
     <PackageReference Include="prometheus-net" Version="4.0.0" />
     <PackageReference Include="prometheus-net.AspNetCore" Version="4.0.0" />
     <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />

+ 54 - 0
Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs

@@ -0,0 +1,54 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Middleware
+{
+    /// <summary>
+    /// Removes /emby and /mediabrowser from requested route.
+    /// </summary>
+    public class LegacyEmbyRouteRewriteMiddleware
+    {
+        private const string EmbyPath = "/emby";
+        private const string MediabrowserPath = "/mediabrowser";
+
+        private readonly RequestDelegate _next;
+        private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class.
+        /// </summary>
+        /// <param name="next">The next delegate in the pipeline.</param>
+        /// <param name="logger">The logger.</param>
+        public LegacyEmbyRouteRewriteMiddleware(
+            RequestDelegate next,
+            ILogger<LegacyEmbyRouteRewriteMiddleware> logger)
+        {
+            _next = next;
+            _logger = logger;
+        }
+
+        /// <summary>
+        /// Executes the middleware action.
+        /// </summary>
+        /// <param name="httpContext">The current HTTP context.</param>
+        /// <returns>The async task.</returns>
+        public async Task Invoke(HttpContext httpContext)
+        {
+            var localPath = httpContext.Request.Path.ToString();
+            if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase))
+            {
+                httpContext.Request.Path = localPath[EmbyPath.Length..];
+                _logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath);
+            }
+            else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase))
+            {
+                httpContext.Request.Path = localPath[MediabrowserPath.Length..];
+                _logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath);
+            }
+
+            await _next(httpContext).ConfigureAwait(false);
+        }
+    }
+}

+ 47 - 0
Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs

@@ -0,0 +1,47 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Middleware
+{
+    /// <summary>
+    /// Redirect requests to robots.txt to web/robots.txt.
+    /// </summary>
+    public class RobotsRedirectionMiddleware
+    {
+        private readonly RequestDelegate _next;
+        private readonly ILogger<RobotsRedirectionMiddleware> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="RobotsRedirectionMiddleware"/> class.
+        /// </summary>
+        /// <param name="next">The next delegate in the pipeline.</param>
+        /// <param name="logger">The logger.</param>
+        public RobotsRedirectionMiddleware(
+            RequestDelegate next,
+            ILogger<RobotsRedirectionMiddleware> logger)
+        {
+            _next = next;
+            _logger = logger;
+        }
+
+        /// <summary>
+        /// Executes the middleware action.
+        /// </summary>
+        /// <param name="httpContext">The current HTTP context.</param>
+        /// <returns>The async task.</returns>
+        public async Task Invoke(HttpContext httpContext)
+        {
+            var localPath = httpContext.Request.Path.ToString();
+            if (string.Equals(localPath, "/robots.txt", StringComparison.OrdinalIgnoreCase))
+            {
+                _logger.LogDebug("Redirecting robots.txt request to web/robots.txt");
+                httpContext.Response.Redirect("web/robots.txt");
+                return;
+            }
+
+            await _next(httpContext).ConfigureAwait(false);
+        }
+    }
+}

+ 8 - 1
Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs

@@ -81,6 +81,7 @@ namespace Jellyfin.Server.Migrations.Routines
                 { "unstable", ChromecastVersion.Unstable }
             };
 
+            var customDisplayPrefs = new HashSet<string>();
             var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
             using (var connection = SQLite3.Open(dbFilePath, ConnectionFlags.ReadOnly, null))
             {
@@ -185,7 +186,13 @@ namespace Jellyfin.Server.Migrations.Routines
 
                     foreach (var (key, value) in dto.CustomPrefs)
                     {
-                        dbContext.Add(new CustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client, key, value));
+                        // Custom display preferences can have a key collision.
+                        var indexKey = $"{displayPreferences.UserId}|{itemId}|{displayPreferences.Client}|{key}";
+                        if (!customDisplayPrefs.Contains(indexKey))
+                        {
+                            dbContext.Add(new CustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client, key, value));
+                            customDisplayPrefs.Add(indexKey);
+                        }
                     }
 
                     dbContext.Add(displayPreferences);

+ 4 - 0
Jellyfin.Server/Startup.cs

@@ -128,6 +128,8 @@ namespace Jellyfin.Server
                     mainApp.UseHttpsRedirection();
                 }
 
+                // This must be injected before any path related middleware.
+                mainApp.UsePathTrim();
                 mainApp.UseStaticFiles();
                 if (appConfig.HostWebClient())
                 {
@@ -142,6 +144,8 @@ namespace Jellyfin.Server
                         RequestPath = "/web",
                         ContentTypeProvider = extensionProvider
                     });
+
+                    mainApp.UseRobotsRedirection();
                 }
 
                 mainApp.UseAuthentication();

+ 30 - 0
MediaBrowser.Common/Json/Converters/JsonBoolNumberConverter.cs

@@ -0,0 +1,30 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+    /// <summary>
+    /// Converts a number to a boolean.
+    /// This is needed for HDHomerun.
+    /// </summary>
+    public class JsonBoolNumberConverter : JsonConverter<bool>
+    {
+        /// <inheritdoc />
+        public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+        {
+            if (reader.TokenType == JsonTokenType.Number)
+            {
+                return Convert.ToBoolean(reader.GetInt32());
+            }
+
+            return reader.GetBoolean();
+        }
+
+        /// <inheritdoc />
+        public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
+        {
+            writer.WriteBooleanValue(value);
+        }
+    }
+}

+ 2 - 9
MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs

@@ -1,4 +1,5 @@
 using System;
+using System.Globalization;
 using System.Text.Json;
 using System.Text.Json.Serialization;
 
@@ -13,21 +14,13 @@ namespace MediaBrowser.Common.Json.Converters
         public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
         {
             var guidStr = reader.GetString();
-
             return guidStr == null ? Guid.Empty : new Guid(guidStr);
         }
 
         /// <inheritdoc />
         public override void Write(Utf8JsonWriter writer, Guid value, JsonSerializerOptions options)
         {
-            if (value == Guid.Empty)
-            {
-                writer.WriteNullValue();
-            }
-            else
-            {
-                writer.WriteStringValue(value);
-            }
+            writer.WriteStringValue(value.ToString("N", CultureInfo.InvariantCulture));
         }
     }
 }

+ 1 - 0
MediaBrowser.Common/Json/JsonDefaults.cs

@@ -43,6 +43,7 @@ namespace MediaBrowser.Common.Json
             options.Converters.Add(new JsonVersionConverter());
             options.Converters.Add(new JsonStringEnumConverter());
             options.Converters.Add(new JsonNullableStructConverterFactory());
+            options.Converters.Add(new JsonBoolNumberConverter());
 
             return options;
         }

+ 4 - 4
MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs

@@ -48,10 +48,10 @@ namespace MediaBrowser.Controller.BaseItemManager
                 return !baseItem.EnableMediaSourceDisplay;
             }
 
-            var typeOptions = libraryOptions.GetTypeOptions(GetType().Name);
+            var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name);
             if (typeOptions != null)
             {
-                return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
+                return typeOptions.MetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
             }
 
             if (!libraryOptions.EnableInternetProviders)
@@ -61,7 +61,7 @@ namespace MediaBrowser.Controller.BaseItemManager
 
             var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase));
 
-            return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
+            return itemConfig == null || !itemConfig.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
         }
 
         /// <inheritdoc />
@@ -79,7 +79,7 @@ namespace MediaBrowser.Controller.BaseItemManager
                 return !baseItem.EnableMediaSourceDisplay;
             }
 
-            var typeOptions = libraryOptions.GetTypeOptions(GetType().Name);
+            var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name);
             if (typeOptions != null)
             {
                 return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);

+ 1 - 0
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -1385,6 +1385,7 @@ namespace MediaBrowser.Controller.Entities
                         new List<FileSystemMetadata>();
 
                     var ownedItemsChanged = await RefreshedOwnedItems(options, files, cancellationToken).ConfigureAwait(false);
+                    await LibraryManager.UpdateImagesAsync(this).ConfigureAwait(false); // ensure all image properties in DB are fresh
 
                     if (ownedItemsChanged)
                     {

+ 5 - 0
MediaBrowser.Controller/Entities/Folder.cs

@@ -354,6 +354,11 @@ namespace MediaBrowser.Controller.Entities
                         {
                             await currentChild.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
                         }
+                        else
+                        {
+                            // metadata is up-to-date; make sure DB has correct images dimensions and hash
+                            await LibraryManager.UpdateImagesAsync(currentChild).ConfigureAwait(false);
+                        }
 
                         continue;
                     }

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

@@ -571,7 +571,7 @@ namespace MediaBrowser.Controller.Library
             string videoPath,
             string[] files);
 
-        void RunMetadataSavers(IReadOnlyList<BaseItem> items, ItemUpdateType updateReason);
+        Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason);
 
         BaseItem GetParentItem(string parentId, Guid? userId);
 

+ 2 - 1
MediaBrowser.Controller/Library/IUserManager.cs

@@ -93,7 +93,8 @@ namespace MediaBrowser.Controller.Library
         /// Deletes the specified user.
         /// </summary>
         /// <param name="userId">The id of the user to be deleted.</param>
-        void DeleteUser(Guid userId);
+        /// <returns>A task representing the deletion of the user.</returns>
+        Task DeleteUserAsync(Guid userId);
 
         /// <summary>
         /// Resets the password.

+ 11 - 0
MediaBrowser.Controller/Session/ISessionManager.cs

@@ -46,6 +46,11 @@ namespace MediaBrowser.Controller.Session
 
         event EventHandler<SessionEventArgs> SessionActivity;
 
+        /// <summary>
+        /// Occurs when [session controller connected].
+        /// </summary>
+        event EventHandler<SessionEventArgs> SessionControllerConnected;
+
         /// <summary>
         /// Occurs when [capabilities changed].
         /// </summary>
@@ -78,6 +83,12 @@ namespace MediaBrowser.Controller.Session
         /// <param name="user">The user.</param>
         SessionInfo LogSessionActivity(string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, Jellyfin.Data.Entities.User user);
 
+        /// <summary>
+        /// Used to report that a session controller has connected.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        void OnSessionControllerConnected(SessionInfo session);
+
         void UpdateDeviceName(string sessionId, string reportedDeviceName);
 
         /// <summary>

+ 7 - 0
MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs

@@ -51,5 +51,12 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <param name="request">The request.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Checks whether a user has an active session using SyncPlay.
+        /// </summary>
+        /// <param name="userId">The user identifier to check.</param>
+        /// <returns><c>true</c> if the user is using SyncPlay; <c>false</c> otherwise.</returns>
+        bool IsUserActive(Guid userId);
     }
 }

+ 5 - 2
MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs

@@ -603,16 +603,19 @@ namespace MediaBrowser.MediaEncoding.Encoder
             }
 
             // Use ffmpeg to sample 100 (we can drop this if required using thumbnail=50 for 50 frames) frames and pick the best thumbnail. Have a fall back just in case.
+            // mpegts need larger batch size otherwise the corrupted thumbnail will be created. Larger batch size will lower the processing speed.
             var enableThumbnail = useIFrame && !string.Equals("wtv", container, StringComparison.OrdinalIgnoreCase);
             if (enableThumbnail)
             {
+                var useLargerBatchSize = string.Equals("mpegts", container, StringComparison.OrdinalIgnoreCase);
+                var batchSize = useLargerBatchSize ? "50" : "24";
                 if (string.IsNullOrEmpty(vf))
                 {
-                    vf = "-vf thumbnail=24";
+                    vf = "-vf thumbnail=" + batchSize;
                 }
                 else
                 {
-                    vf += ",thumbnail=24";
+                    vf += ",thumbnail=" + batchSize;
                 }
             }
 

+ 2 - 2
MediaBrowser.Model/Configuration/EncodingOptions.cs

@@ -88,11 +88,11 @@ namespace MediaBrowser.Model.Configuration
             // The left side of the dot is the platform number, and the right side is the device number on the platform.
             OpenclDevice = "0.0";
             EnableTonemapping = false;
-            TonemappingAlgorithm = "reinhard";
+            TonemappingAlgorithm = "hable";
             TonemappingRange = "auto";
             TonemappingDesat = 0;
             TonemappingThreshold = 0.8;
-            TonemappingPeak = 0;
+            TonemappingPeak = 100;
             TonemappingParam = 0;
             H264Crf = 23;
             H265Crf = 28;

+ 5 - 5
MediaBrowser.Model/Dto/BaseItemDto.cs

@@ -152,7 +152,7 @@ namespace MediaBrowser.Model.Dto
         /// Gets or sets the channel identifier.
         /// </summary>
         /// <value>The channel identifier.</value>
-        public Guid ChannelId { get; set; }
+        public Guid? ChannelId { get; set; }
 
         public string ChannelName { get; set; }
 
@@ -270,7 +270,7 @@ namespace MediaBrowser.Model.Dto
         /// Gets or sets the parent id.
         /// </summary>
         /// <value>The parent id.</value>
-        public Guid ParentId { get; set; }
+        public Guid? ParentId { get; set; }
 
         /// <summary>
         /// Gets or sets the type.
@@ -344,13 +344,13 @@ namespace MediaBrowser.Model.Dto
         /// Gets or sets the series id.
         /// </summary>
         /// <value>The series id.</value>
-        public Guid SeriesId { get; set; }
+        public Guid? SeriesId { get; set; }
 
         /// <summary>
         /// Gets or sets the season identifier.
         /// </summary>
         /// <value>The season identifier.</value>
-        public Guid SeasonId { get; set; }
+        public Guid? SeasonId { get; set; }
 
         /// <summary>
         /// Gets or sets the special feature count.
@@ -428,7 +428,7 @@ namespace MediaBrowser.Model.Dto
         /// Gets or sets the album id.
         /// </summary>
         /// <value>The album id.</value>
-        public Guid AlbumId { get; set; }
+        public Guid? AlbumId { get; set; }
 
         /// <summary>
         /// Gets or sets the album image tag.

+ 2 - 2
MediaBrowser.Model/Users/UserPolicy.cs

@@ -111,7 +111,7 @@ namespace MediaBrowser.Model.Users
         /// Gets or sets a value indicating what SyncPlay features the user can access.
         /// </summary>
         /// <value>Access level to SyncPlay features.</value>
-        public SyncPlayAccess SyncPlayAccess { get; set; }
+        public SyncPlayUserAccessType SyncPlayAccess { get; set; }
 
         public UserPolicy()
         {
@@ -160,7 +160,7 @@ namespace MediaBrowser.Model.Users
             EnableContentDownloading = true;
             EnablePublicSharing = true;
             EnableRemoteAccess = true;
-            SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups;
+            SyncPlayAccess = SyncPlayUserAccessType.CreateAndJoinGroups;
         }
     }
 }

+ 4 - 3
MediaBrowser.Providers/Manager/MetadataService.cs

@@ -229,7 +229,7 @@ namespace MediaBrowser.Providers.Manager
             await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
         }
 
-        private Task SavePeopleMetadataAsync(List<PersonInfo> people, LibraryOptions libraryOptions, CancellationToken cancellationToken)
+        private async Task SavePeopleMetadataAsync(List<PersonInfo> people, LibraryOptions libraryOptions, CancellationToken cancellationToken)
         {
             var personsToSave = new List<BaseItem>();
 
@@ -239,6 +239,7 @@ namespace MediaBrowser.Providers.Manager
 
                 if (person.ProviderIds.Count > 0 || !string.IsNullOrWhiteSpace(person.ImageUrl))
                 {
+                    var itemUpdateType = ItemUpdateType.MetadataDownload;
                     var saveEntity = false;
                     var personEntity = LibraryManager.GetPerson(person.Name);
                     foreach (var id in person.ProviderIds)
@@ -261,18 +262,18 @@ namespace MediaBrowser.Providers.Manager
                             0);
 
                         saveEntity = true;
+                        itemUpdateType = ItemUpdateType.ImageUpdate;
                     }
 
                     if (saveEntity)
                     {
                         personsToSave.Add(personEntity);
+                        await LibraryManager.RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
                     }
                 }
             }
 
-            LibraryManager.RunMetadataSavers(personsToSave, ItemUpdateType.MetadataDownload);
             LibraryManager.CreateItems(personsToSave, null, CancellationToken.None);
-            return Task.CompletedTask;
         }
 
         protected virtual Task AfterMetadataRefresh(TItemType item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)

+ 1 - 1
MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs

@@ -425,7 +425,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
             {
                 var person = new PersonInfo
                 {
-                    Name = result.Director.Trim(),
+                    Name = result.Writer.Trim(),
                     Type = PersonType.Writer
                 };
 

+ 1 - 7
README.md

@@ -105,12 +105,6 @@ There are three options to get the files for the web client.
 2. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web)
 3. Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at `C:\Program Files\Jellyfin\Server\jellyfin-web`
 
-Once you have a copy of the built web client files, you need to copy them into a specific directory.
-
-> `<repository root>/Mediabrowser.WebDashboard/jellyfin-web`
-
-As part of the build process, this folder will be copied to the build output directory, where it can be accessed by the server.
-
 ### Running The Server
 
 The following instructions will help you get the project up and running via the command line, or your preferred IDE.
@@ -133,7 +127,7 @@ To run the server from the command line you can use the `dotnet run` command. Th
 
 ```bash
 cd jellyfin                          # Move into the repository directory
-dotnet run --project Jellyfin.Server # Run the server startup project
+dotnet run --project Jellyfin.Server --webdir /absolute/path/to/jellyfin-web/dist # Run the server startup project
 ```
 
 A second option is to build the project and then run the resulting executable file directly. When running the executable directly you can easily add command line options. Add the `--help` flag to list details on all the supported command line options.

+ 1 - 1
deployment/Dockerfile.debian.amd64

@@ -16,7 +16,7 @@ RUN apt-get update \
 
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
-RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.debian.arm64

@@ -16,7 +16,7 @@ RUN apt-get update \
 
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
-RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.debian.armhf

@@ -16,7 +16,7 @@ RUN apt-get update \
 
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
-RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.linux.amd64

@@ -16,7 +16,7 @@ RUN apt-get update \
 
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
-RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.macos

@@ -16,7 +16,7 @@ RUN apt-get update \
 
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
-RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.portable

@@ -15,7 +15,7 @@ RUN apt-get update \
 
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
-RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.ubuntu.amd64

@@ -16,7 +16,7 @@ RUN apt-get update \
 
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
-RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.ubuntu.arm64

@@ -16,7 +16,7 @@ RUN apt-get update \
 
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
-RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.ubuntu.armhf

@@ -16,7 +16,7 @@ RUN apt-get update \
 
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
-RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.windows.amd64

@@ -15,7 +15,7 @@ RUN apt-get update \
 
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
-RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

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

@@ -16,9 +16,9 @@
     <PackageReference Include="AutoFixture" Version="4.14.0" />
     <PackageReference Include="AutoFixture.AutoMoq" Version="4.14.0" />
     <PackageReference Include="AutoFixture.Xunit2" Version="4.14.0" />
-    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="5.0.0" />
+    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="5.0.1" />
     <PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" />
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
     <PackageReference Include="xunit" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />

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

@@ -13,7 +13,7 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
     <PackageReference Include="xunit" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />

+ 34 - 0
tests/Jellyfin.Common.Tests/Json/JsonBoolNumberTests.cs

@@ -0,0 +1,34 @@
+using System.Text.Json;
+using MediaBrowser.Common.Json.Converters;
+using Xunit;
+
+namespace Jellyfin.Common.Tests.Json
+{
+    public static class JsonBoolNumberTests
+    {
+        [Theory]
+        [InlineData("1", true)]
+        [InlineData("0", false)]
+        [InlineData("2", true)]
+        [InlineData("true", true)]
+        [InlineData("false", false)]
+        public static void Deserialize_Number_Valid_Success(string input, bool? output)
+        {
+            var options = new JsonSerializerOptions();
+            options.Converters.Add(new JsonBoolNumberConverter());
+            var value = JsonSerializer.Deserialize<bool>(input, options);
+            Assert.Equal(value, output);
+        }
+
+        [Theory]
+        [InlineData(true, "true")]
+        [InlineData(false, "false")]
+        public static void Serialize_Bool_Success(bool input, string output)
+        {
+            var options = new JsonSerializerOptions();
+            options.Converters.Add(new JsonBoolNumberConverter());
+            var value = JsonSerializer.Serialize(input, options);
+            Assert.Equal(value, output);
+        }
+    }
+}

+ 20 - 3
tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs

@@ -1,9 +1,10 @@
 using System;
+using System.Globalization;
 using System.Text.Json;
 using MediaBrowser.Common.Json.Converters;
 using Xunit;
 
-namespace Jellyfin.Common.Tests.Extensions
+namespace Jellyfin.Common.Tests.Json
 {
     public class JsonGuidConverterTests
     {
@@ -44,9 +45,25 @@ namespace Jellyfin.Common.Tests.Extensions
         }
 
         [Fact]
-        public void Serialize_EmptyGuid_Null()
+        public void Serialize_EmptyGuid_EmptyGuid()
         {
-            Assert.Equal("null", JsonSerializer.Serialize(Guid.Empty, _options));
+            Assert.Equal($"\"{Guid.Empty:N}\"", JsonSerializer.Serialize(Guid.Empty, _options));
+        }
+
+        [Fact]
+        public void Serialize_Valid_NoDash_Success()
+        {
+            var guid = new Guid("531797E9-9457-40E0-88BC-B1D6D38752FA");
+            var str = JsonSerializer.Serialize(guid, _options);
+            Assert.Equal($"\"{guid:N}\"", str);
+        }
+
+        [Fact]
+        public void Serialize_Nullable_Success()
+        {
+            Guid? guid = new Guid("531797E9-9457-40E0-88BC-B1D6D38752FA");
+            var str = JsonSerializer.Serialize(guid, _options);
+            Assert.Equal($"\"{guid:N}\"", str);
         }
     }
 }

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

@@ -13,7 +13,7 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
     <PackageReference Include="xunit" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />

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

@@ -8,7 +8,7 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
     <PackageReference Include="xunit" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />

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

@@ -19,7 +19,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
     <PackageReference Include="xunit" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />

Vissa filer visades inte eftersom för många filer har ändrats