Przeglądaj źródła

Merge branch 'master' into PluginDowngrade

BaronGreenback 4 lat temu
rodzic
commit
67c480ad53
100 zmienionych plików z 921 dodań i 511 usunięć
  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];
             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++)
             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);
             _logger.LogDebug("{0} - Playlist created", _session.DeviceName);

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

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

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

@@ -88,38 +88,12 @@ namespace Emby.Server.Implementations.Configuration
             var newConfig = (ServerConfiguration)newConfiguration;
             var newConfig = (ServerConfiguration)newConfiguration;
 
 
             ValidateMetadataPath(newConfig);
             ValidateMetadataPath(newConfig);
-            ValidateSslCertificate(newConfig);
 
 
             ConfigurationUpdating?.Invoke(this, new GenericEventArgs<ServerConfiguration>(newConfig));
             ConfigurationUpdating?.Invoke(this, new GenericEventArgs<ServerConfiguration>(newConfig));
 
 
             base.ReplaceConfiguration(newConfiguration);
             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>
         /// <summary>
         /// Validates the metadata path.
         /// Validates the metadata path.
         /// </summary>
         /// </summary>

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

@@ -1138,7 +1138,10 @@ namespace Emby.Server.Implementations.Dto
                     if (episodeSeries != null)
                     if (episodeSeries != null)
                     {
                     {
                         dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, episodeSeries, ImageType.Primary);
                         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)
                     if (series != null)
                     {
                     {
                         dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, series, ImageType.Primary);
                         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.ResponseCompression" Version="2.2.0" />
     <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" />
     <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" />
     <PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="2.2.1" />
     <PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="2.2.1" />
-    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="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.Caching.Memory" Version="5.0.0" />
     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
     <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" />
     <PackageReference Include="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;
                         updateToken = true;
                     }
                     }
 
 
-                    authInfo.IsApiKey = true;
+                    authInfo.IsApiKey = false;
                 }
                 }
                 else
                 else
                 {
                 {
-                    authInfo.IsApiKey = false;
+                    authInfo.IsApiKey = true;
                 }
                 }
 
 
                 if (updateToken)
                 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.Entities;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Library;
 using MediaBrowser.Model.Library;
-using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Tasks;
 using MediaBrowser.Model.Tasks;
 using MediaBrowser.Providers.MediaInfo;
 using MediaBrowser.Providers.MediaInfo;
@@ -1955,9 +1954,12 @@ namespace Emby.Server.Implementations.Library
         }
         }
 
 
         /// <inheritdoc />
         /// <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);
             _itemRepository.SaveItems(items, cancellationToken);
 
 
@@ -1988,25 +1990,22 @@ namespace Emby.Server.Implementations.Library
                     }
                     }
                 }
                 }
             }
             }
-
-            return Task.CompletedTask;
         }
         }
 
 
         /// <inheritdoc />
         /// <inheritdoc />
         public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
         public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
             => UpdateItemsAsync(new[] { item }, parent, updateReason, 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>
         /// <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>
     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)
         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
             return list
                 .OrderBy(i =>
                 .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
                     if (index == -1
                         && i is UserView view
                         && i is UserView view
                         && view.DisplayParentId != Guid.Empty)
                         && 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;
                     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,
             CancellationToken cancellationToken,
             HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
             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));
             options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
             return await Send(options, false, 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);
             options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
 
 
             using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
             using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
+            response.EnsureSuccessStatusCode();
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Token>(stream).ConfigureAwait(false);
             var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Token>(stream).ConfigureAwait(false);
             if (string.Equals(root.message, "OK", StringComparison.Ordinal))
             if (string.Equals(root.message, "OK", StringComparison.Ordinal))
@@ -701,6 +702,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             try
             try
             {
             {
                 using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
                 using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
+                httpResponse.EnsureSuccessStatusCode();
                 await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
                 await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
                 using var response = httpResponse.Content;
                 using var response = httpResponse.Content;
                 var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Lineups>(stream).ConfigureAwait(false);
                 var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Lineups>(stream).ConfigureAwait(false);
@@ -709,7 +711,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             }
             }
             catch (HttpRequestException ex)
             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)
                 if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest)
                 {
                 {
                     return false;
                     return false;

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

@@ -1928,7 +1928,7 @@ namespace Emby.Server.Implementations.LiveTv
 
 
                 foreach (var programDto in currentProgramDtos)
                 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;
                         channelDto.CurrentProgram = programDto;
                     }
                     }
@@ -2018,7 +2018,7 @@ namespace Emby.Server.Implementations.LiveTv
             info.DayPattern = _tvDtoService.GetDayPattern(info.Days);
             info.DayPattern = _tvDtoService.GetDayPattern(info.Days);
 
 
             info.Name = program.Name;
             info.Name = program.Name;
-            info.ChannelId = programDto.ChannelId;
+            info.ChannelId = programDto.ChannelId ?? Guid.Empty;
             info.ChannelName = programDto.ChannelName;
             info.ChannelName = programDto.ChannelName;
             info.StartDate = program.StartDate;
             info.StartDate = program.StartDate;
             info.Name = program.Name;
             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;
 using System.Net.Http;
 using System.Net.Http;
 using System.Text.Json;
 using System.Text.Json;
+using System.Text.Json.Serialization;
 using System.Threading;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Json;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Configuration;
@@ -37,6 +39,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
         private readonly INetworkManager _networkManager;
         private readonly INetworkManager _networkManager;
         private readonly IStreamHelper _streamHelper;
         private readonly IStreamHelper _streamHelper;
 
 
+        private readonly JsonSerializerOptions _jsonOptions;
+
         private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>();
         private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>();
 
 
         public HdHomerunHost(
         public HdHomerunHost(
@@ -56,6 +60,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             _socketFactory = socketFactory;
             _socketFactory = socketFactory;
             _networkManager = networkManager;
             _networkManager = networkManager;
             _streamHelper = streamHelper;
             _streamHelper = streamHelper;
+
+            _jsonOptions = JsonDefaults.GetOptions();
         }
         }
 
 
         public string Name => "HD Homerun";
         public string Name => "HD Homerun";
@@ -67,13 +73,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
         private string GetChannelId(TunerHostInfo info, Channels i)
         private string GetChannelId(TunerHostInfo info, Channels i)
             => ChannelIdPrefix + i.GuideNumber;
             => 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);
             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);
             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);
             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>();
                 .ConfigureAwait(false) ?? new List<Channels>();
 
 
             if (info.ImportFavoritesOnly)
             if (info.ImportFavoritesOnly)
@@ -100,7 +106,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 Id = GetChannelId(info, i),
                 Id = GetChannelId(info, i),
                 IsFavorite = i.Favorite,
                 IsFavorite = i.Favorite,
                 TunerHostId = info.Id,
                 TunerHostId = info.Id,
-                IsHD = i.HD == 1,
+                IsHD = i.HD,
                 AudioCodec = i.AudioCodec,
                 AudioCodec = i.AudioCodec,
                 VideoCodec = i.VideoCodec,
                 VideoCodec = i.VideoCodec,
                 ChannelType = ChannelType.TV,
                 ChannelType = ChannelType.TV,
@@ -109,7 +115,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             }).Cast<ChannelInfo>().ToList();
             }).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;
             var cacheKey = info.Id;
 
 
@@ -127,10 +133,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             try
             try
             {
             {
                 using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
                 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);
                     .ConfigureAwait(false);
+                response.EnsureSuccessStatusCode();
                 await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
                 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);
                     .ConfigureAwait(false);
 
 
                 if (!string.IsNullOrEmpty(cacheKey))
                 if (!string.IsNullOrEmpty(cacheKey))
@@ -328,25 +335,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             return new Uri(url).AbsoluteUri.TrimEnd('/');
             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()
         protected EncodingOptions GetEncodingOptions()
         {
         {
             return Config.GetConfiguration<EncodingOptions>("encoding");
             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)
         public async Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken)
         {
         {
             lock (_modelCache)
             lock (_modelCache)
@@ -762,7 +714,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             return list;
             return list;
         }
         }
 
 
-        private async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken)
+        internal async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken)
         {
         {
             var hostInfo = new TunerHostInfo
             var hostInfo = new TunerHostInfo
             {
             {
@@ -774,6 +726,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
 
 
             hostInfo.DeviceId = modelInfo.DeviceID;
             hostInfo.DeviceId = modelInfo.DeviceID;
             hostInfo.FriendlyName = modelInfo.FriendlyName;
             hostInfo.FriendlyName = modelInfo.FriendlyName;
+            hostInfo.TunerCount = modelInfo.TunerCount;
 
 
             return hostInfo;
             return hostInfo;
         }
         }

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

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

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

@@ -1,9 +1,9 @@
 {
 {
     "Albums": "Albums",
     "Albums": "Albums",
-    "AppDeviceValues": "Application : {0}, Appareil : {1}",
+    "AppDeviceValues": "App : {0}, Appareil : {1}",
     "Application": "Application",
     "Application": "Application",
     "Artists": "Artistes",
     "Artists": "Artistes",
-    "AuthenticationSucceededWithUserName": "{0} s'est authentifié avec succès",
+    "AuthenticationSucceededWithUserName": "{0} authentifié avec succès",
     "Books": "Livres",
     "Books": "Livres",
     "CameraImageUploadedFrom": "Une nouvelle image de caméra a été téléchargée depuis {0}",
     "CameraImageUploadedFrom": "Une nouvelle image de caméra a été téléchargée depuis {0}",
     "Channels": "Chaînes",
     "Channels": "Chaînes",
@@ -11,12 +11,12 @@
     "Collections": "Collections",
     "Collections": "Collections",
     "DeviceOfflineWithName": "{0} s'est déconnecté",
     "DeviceOfflineWithName": "{0} s'est déconnecté",
     "DeviceOnlineWithName": "{0} est connecté",
     "DeviceOnlineWithName": "{0} est connecté",
-    "FailedLoginAttemptWithUserName": "Échec d'une tentative de connexion de {0}",
+    "FailedLoginAttemptWithUserName": "Tentative de connexion échoué par {0}",
     "Favorites": "Favoris",
     "Favorites": "Favoris",
     "Folders": "Dossiers",
     "Folders": "Dossiers",
     "Genres": "Genres",
     "Genres": "Genres",
     "HeaderAlbumArtists": "Artistes de l'album",
     "HeaderAlbumArtists": "Artistes de l'album",
-    "HeaderContinueWatching": "Continuer à regarder",
+    "HeaderContinueWatching": "Reprendre le visionnement",
     "HeaderFavoriteAlbums": "Albums favoris",
     "HeaderFavoriteAlbums": "Albums favoris",
     "HeaderFavoriteArtists": "Artistes favoris",
     "HeaderFavoriteArtists": "Artistes favoris",
     "HeaderFavoriteEpisodes": "Épisodes favoris",
     "HeaderFavoriteEpisodes": "Épisodes favoris",
@@ -26,12 +26,12 @@
     "HeaderNextUp": "À Suivre",
     "HeaderNextUp": "À Suivre",
     "HeaderRecordingGroups": "Groupes d'enregistrements",
     "HeaderRecordingGroups": "Groupes d'enregistrements",
     "HomeVideos": "Vidéos personnelles",
     "HomeVideos": "Vidéos personnelles",
-    "Inherit": "Hériter",
+    "Inherit": "Hérite",
     "ItemAddedWithName": "{0} a été ajouté à la médiathèque",
     "ItemAddedWithName": "{0} a été ajouté à la médiathèque",
     "ItemRemovedWithName": "{0} a été supprimé de la médiathèque",
     "ItemRemovedWithName": "{0} a été supprimé de la médiathèque",
     "LabelIpAddressValue": "Adresse IP : {0}",
     "LabelIpAddressValue": "Adresse IP : {0}",
     "LabelRunningTimeValue": "Durée : {0}",
     "LabelRunningTimeValue": "Durée : {0}",
-    "Latest": "Derniers",
+    "Latest": "Plus récent",
     "MessageApplicationUpdated": "Le serveur Jellyfin a été mis à jour",
     "MessageApplicationUpdated": "Le serveur Jellyfin a été mis à jour",
     "MessageApplicationUpdatedTo": "Le serveur Jellyfin a été mis à jour vers la version {0}",
     "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",
     "MessageNamedServerConfigurationUpdatedWithValue": "La configuration de la section {0} du serveur a été mise à jour",
@@ -40,15 +40,15 @@
     "Movies": "Films",
     "Movies": "Films",
     "Music": "Musique",
     "Music": "Musique",
     "MusicVideos": "Vidéos musicales",
     "MusicVideos": "Vidéos musicales",
-    "NameInstallFailed": "{0} échec d'installation",
+    "NameInstallFailed": "échec d'installation de {0}",
     "NameSeasonNumber": "Saison {0}",
     "NameSeasonNumber": "Saison {0}",
     "NameSeasonUnknown": "Saison Inconnue",
     "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",
     "NotificationOptionApplicationUpdateAvailable": "Mise à jour de l'application disponible",
     "NotificationOptionApplicationUpdateInstalled": "Mise à jour de l'application installée",
     "NotificationOptionApplicationUpdateInstalled": "Mise à jour de l'application installée",
     "NotificationOptionAudioPlayback": "Lecture audio démarrée",
     "NotificationOptionAudioPlayback": "Lecture audio démarrée",
     "NotificationOptionAudioPlaybackStopped": "Lecture audio arrêté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",
     "NotificationOptionInstallationFailed": "Échec d'installation",
     "NotificationOptionNewLibraryContent": "Nouveau contenu ajouté",
     "NotificationOptionNewLibraryContent": "Nouveau contenu ajouté",
     "NotificationOptionPluginError": "Erreur d'extension",
     "NotificationOptionPluginError": "Erreur d'extension",
@@ -70,9 +70,9 @@
     "ScheduledTaskFailedWithName": "{0} a échoué",
     "ScheduledTaskFailedWithName": "{0} a échoué",
     "ScheduledTaskStartedWithName": "{0} a commencé",
     "ScheduledTaskStartedWithName": "{0} a commencé",
     "ServerNameNeedsToBeRestarted": "{0} doit être redémarré",
     "ServerNameNeedsToBeRestarted": "{0} doit être redémarré",
-    "Shows": "Émissions",
+    "Shows": "Séries",
     "Songs": "Chansons",
     "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}",
     "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
     "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}",
     "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}",
     "Sync": "Synchroniser",
     "Sync": "Synchroniser",
@@ -80,39 +80,43 @@
     "TvShows": "Séries Télé",
     "TvShows": "Séries Télé",
     "User": "Utilisateur",
     "User": "Utilisateur",
     "UserCreatedWithName": "L'utilisateur {0} a été créé",
     "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é",
     "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}",
     "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",
     "ValueHasBeenAddedToLibrary": "{0} a été ajouté à votre médiathèque",
     "ValueSpecialEpisodeName": "Spécial - {0}",
     "ValueSpecialEpisodeName": "Spécial - {0}",
     "VersionNumber": "Version {0}",
     "VersionNumber": "Version {0}",
-    "TasksLibraryCategory": "Bibliothèque",
+    "TasksLibraryCategory": "Médiathèque",
     "TasksMaintenanceCategory": "Entretien",
     "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",
     "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",
     "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",
     "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",
     "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",
     "TaskRefreshChapterImages": "Extraire les images de chapitre",
     "TaskRefreshChapterImagesDescription": "Créer des vignettes pour les vidéos qui ont des chapitres.",
     "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",
     "TaskCleanCache": "Nettoyer le répertoire des fichiers temporaires",
     "TasksApplicationCategory": "Application",
     "TasksApplicationCategory": "Application",
     "TaskCleanCacheDescription": "Supprime les fichiers temporaires qui ne sont plus nécessaire pour le système.",
     "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",
     "NotificationOptionUserLockedOut": "Lietotājs bloķēts",
     "LabelRunningTimeValue": "Garums: {0}",
     "LabelRunningTimeValue": "Garums: {0}",
     "Inherit": "Mantot",
     "Inherit": "Mantot",
-    "AppDeviceValues": "Lietotne:{0}, Ierīce:{1}",
+    "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}",
     "VersionNumber": "Versija {0}",
     "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}",
     "UserStoppedPlayingItemWithValues": "{0} ir beidzis atskaņot {1} uz {2}",
     "UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}",
     "UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}",
     "UserPasswordChangedWithName": "Parole nomainīta lietotājam {0}",
     "UserPasswordChangedWithName": "Parole nomainīta lietotājam {0}",
@@ -95,7 +95,7 @@
     "TaskRefreshChapterImages": "Izvilkt Nodaļu Attēlus",
     "TaskRefreshChapterImages": "Izvilkt Nodaļu Attēlus",
     "TasksApplicationCategory": "Lietotne",
     "TasksApplicationCategory": "Lietotne",
     "TasksLibraryCategory": "Bibliotēka",
     "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",
     "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošus subtitrus",
     "TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.",
     "TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.",
     "TaskRefreshChannels": "Atjaunot Kanālus",
     "TaskRefreshChannels": "Atjaunot Kanālus",
@@ -103,14 +103,19 @@
     "TaskCleanTranscode": "Iztīrīt Trans-kodēšanas Mapi",
     "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.",
     "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",
     "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",
     "TaskRefreshPeople": "Atjaunot Cilvēkus",
     "TaskCleanLogsDescription": "Nodzēš log datnes, kas ir vairāk par {0} dienām vecas.",
     "TaskCleanLogsDescription": "Nodzēš log datnes, kas ir vairāk par {0} dienām vecas.",
     "TaskCleanLogs": "Iztīrīt Logdatņu Mapi",
     "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.",
     "TaskRefreshChapterImagesDescription": "Izveido sīktēlus priekš video ar sadaļām.",
     "TaskCleanCache": "Iztīrīt Kešošanas Mapi",
     "TaskCleanCache": "Iztīrīt Kešošanas Mapi",
     "TasksChannelsCategory": "Interneta Kanāli",
     "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",
     "TasksApplicationCategory": "Aplikacija",
     "TasksLibraryCategory": "Knjižnica",
     "TasksLibraryCategory": "Knjižnica",
     "TasksMaintenanceCategory": "Vzdrževanje",
     "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.Reflection;
 using System.Resources;
 using System.Resources;
+using System.Runtime.CompilerServices;
 using System.Runtime.InteropServices;
 using System.Runtime.InteropServices;
 
 
 // General Information about an assembly is controlled through the following
 // General Information about an assembly is controlled through the following
@@ -14,6 +15,7 @@ using System.Runtime.InteropServices;
 [assembly: AssemblyTrademark("")]
 [assembly: AssemblyTrademark("")]
 [assembly: AssemblyCulture("")]
 [assembly: AssemblyCulture("")]
 [assembly: NeutralResourcesLanguage("en")]
 [assembly: NeutralResourcesLanguage("en")]
+[assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")]
 
 
 // Setting ComVisible to false makes the types in this assembly not visible
 // 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
 // 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 />
         /// <inheritdoc />
         public event EventHandler<SessionEventArgs> SessionActivity;
         public event EventHandler<SessionEventArgs> SessionActivity;
 
 
+        /// <inheritdoc />
+        public event EventHandler<SessionEventArgs> SessionControllerConnected;
+
         /// <summary>
         /// <summary>
         /// Gets all connections.
         /// Gets all connections.
         /// </summary>
         /// </summary>
@@ -312,6 +315,19 @@ namespace Emby.Server.Implementations.Session
             return session;
             return session;
         }
         }
 
 
+        /// <inheritdoc />
+        public void OnSessionControllerConnected(SessionInfo info)
+        {
+            EventHelper.QueueEventIfNotNull(
+                SessionControllerConnected,
+                this,
+                new SessionEventArgs
+                {
+                    SessionInfo = info
+                },
+                _logger);
+        }
+
         /// <inheritdoc />
         /// <inheritdoc />
         public void CloseIfNeeded(SessionInfo session)
         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;
             var controller = (WebSocketController)controllerInfo.Item1;
             controller.AddWebSocket(connection);
             controller.AddWebSocket(connection);
+
+            _sessionManager.OnSessionControllerConnected(session);
         }
         }
 
 
         /// <summary>
         /// <summary>

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

@@ -41,6 +41,12 @@ namespace Emby.Server.Implementations.SyncPlay
         /// </summary>
         /// </summary>
         private readonly ILibraryManager _libraryManager;
         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>
         /// <summary>
         /// The map between sessions and groups.
         /// The map between sessions and groups.
         /// </summary>
         /// </summary>
@@ -81,7 +87,7 @@ namespace Emby.Server.Implementations.SyncPlay
             _sessionManager = sessionManager;
             _sessionManager = sessionManager;
             _libraryManager = libraryManager;
             _libraryManager = libraryManager;
             _logger = loggerFactory.CreateLogger<SyncPlayManager>();
             _logger = loggerFactory.CreateLogger<SyncPlayManager>();
-            _sessionManager.SessionStarted += OnSessionManagerSessionStarted;
+            _sessionManager.SessionControllerConnected += OnSessionControllerConnected;
         }
         }
 
 
         /// <inheritdoc />
         /// <inheritdoc />
@@ -122,6 +128,7 @@ namespace Emby.Server.Implementations.SyncPlay
                     throw new InvalidOperationException("Could not add session to group!");
                     throw new InvalidOperationException("Could not add session to group!");
                 }
                 }
 
 
+                UpdateSessionsCounter(session.UserId, 1);
                 group.CreateGroup(session, request, cancellationToken);
                 group.CreateGroup(session, request, cancellationToken);
             }
             }
         }
         }
@@ -172,6 +179,7 @@ namespace Emby.Server.Implementations.SyncPlay
                         if (existingGroup.GroupId.Equals(request.GroupId))
                         if (existingGroup.GroupId.Equals(request.GroupId))
                         {
                         {
                             // Restore session.
                             // Restore session.
+                            UpdateSessionsCounter(session.UserId, 1);
                             group.SessionJoin(session, request, cancellationToken);
                             group.SessionJoin(session, request, cancellationToken);
                             return;
                             return;
                         }
                         }
@@ -185,6 +193,7 @@ namespace Emby.Server.Implementations.SyncPlay
                         throw new InvalidOperationException("Could not add session to group!");
                         throw new InvalidOperationException("Could not add session to group!");
                     }
                     }
 
 
+                    UpdateSessionsCounter(session.UserId, 1);
                     group.SessionJoin(session, request, cancellationToken);
                     group.SessionJoin(session, request, cancellationToken);
                 }
                 }
             }
             }
@@ -223,6 +232,7 @@ namespace Emby.Server.Implementations.SyncPlay
                             throw new InvalidOperationException("Could not remove session from group!");
                             throw new InvalidOperationException("Could not remove session from group!");
                         }
                         }
 
 
+                        UpdateSessionsCounter(session.UserId, -1);
                         group.SessionLeave(session, request, cancellationToken);
                         group.SessionLeave(session, request, cancellationToken);
 
 
                         if (group.IsGroupEmpty())
                         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>
         /// <summary>
         /// Releases unmanaged and optionally managed resources.
         /// Releases unmanaged and optionally managed resources.
         /// </summary>
         /// </summary>
@@ -329,11 +352,11 @@ namespace Emby.Server.Implementations.SyncPlay
                 return;
                 return;
             }
             }
 
 
-            _sessionManager.SessionStarted -= OnSessionManagerSessionStarted;
+            _sessionManager.SessionControllerConnected -= OnSessionControllerConnected;
             _disposed = true;
             _disposed = true;
         }
         }
 
 
-        private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e)
+        private void OnSessionControllerConnected(object sender, SessionEventArgs e)
         {
         {
             var session = e.SessionInfo;
             var session = e.SessionInfo;
 
 
@@ -343,5 +366,26 @@ namespace Emby.Server.Implementations.SyncPlay
                 JoinGroup(session, request, CancellationToken.None);
                 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.
                         // Don't add a package that doesn't have any compatible versions.
-                        if (package.Versions.Count == 0)
+                        if (package.versions.Count == 0)
                         {
                         {
                             continue;
                             continue;
                         }
                         }
@@ -555,6 +555,7 @@ namespace Emby.Server.Implementations.Updates
 
 
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
                 .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
                 .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
+            response.EnsureSuccessStatusCode();
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 
 
             // CA5351: Do Not Use Broken Cryptographic Algorithms
             // 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 Jellyfin.Data.Enums;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.SyncPlay;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
 
 
@@ -13,20 +14,24 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
     /// </summary>
     /// </summary>
     public class SyncPlayAccessHandler : BaseAuthorizationHandler<SyncPlayAccessRequirement>
     public class SyncPlayAccessHandler : BaseAuthorizationHandler<SyncPlayAccessRequirement>
     {
     {
+        private readonly ISyncPlayManager _syncPlayManager;
         private readonly IUserManager _userManager;
         private readonly IUserManager _userManager;
 
 
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="SyncPlayAccessHandler"/> class.
         /// Initializes a new instance of the <see cref="SyncPlayAccessHandler"/> class.
         /// </summary>
         /// </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="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
         /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
         /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
         /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
         /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
         public SyncPlayAccessHandler(
         public SyncPlayAccessHandler(
+            ISyncPlayManager syncPlayManager,
             IUserManager userManager,
             IUserManager userManager,
             INetworkManager networkManager,
             INetworkManager networkManager,
             IHttpContextAccessor httpContextAccessor)
             IHttpContextAccessor httpContextAccessor)
             : base(userManager, networkManager, httpContextAccessor)
             : base(userManager, networkManager, httpContextAccessor)
         {
         {
+            _syncPlayManager = syncPlayManager;
             _userManager = userManager;
             _userManager = userManager;
         }
         }
 
 
@@ -42,10 +47,52 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
             var userId = ClaimHelpers.GetUserId(context.User);
             var userId = ClaimHelpers.GetUserId(context.User);
             var user = _userManager.GetUserById(userId!.Value);
             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
             else
             {
             {

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

@@ -11,23 +11,15 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
         /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
         /// </summary>
         /// </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;
             RequiredAccess = requiredAccess;
         }
         }
 
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
-        /// </summary>
-        public SyncPlayAccessRequirement()
-        {
-            RequiredAccess = null;
-        }
-
         /// <summary>
         /// <summary>
         /// Gets the required SyncPlay access.
         /// Gets the required SyncPlay access.
         /// </summary>
         /// </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";
         public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl";
 
 
         /// <summary>
         /// <summary>
-        /// Policy name for requiring access to SyncPlay.
+        /// Policy name for accessing SyncPlay.
         /// </summary>
         /// </summary>
-        public const string SyncPlayAccess = "SyncPlayAccess";
+        public const string SyncPlayHasAccess = "SyncPlayHasAccess";
 
 
         /// <summary>
         /// <summary>
-        /// Policy name for requiring group creation access to SyncPlay.
+        /// Policy name for creating a SyncPlay group.
         /// </summary>
         /// </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.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
 
 
 namespace Jellyfin.Api.Controllers
 namespace Jellyfin.Api.Controllers
 {
 {
@@ -22,14 +23,17 @@ namespace Jellyfin.Api.Controllers
     public class DisplayPreferencesController : BaseJellyfinApiController
     public class DisplayPreferencesController : BaseJellyfinApiController
     {
     {
         private readonly IDisplayPreferencesManager _displayPreferencesManager;
         private readonly IDisplayPreferencesManager _displayPreferencesManager;
+        private readonly ILogger<DisplayPreferencesController> _logger;
 
 
         /// <summary>
         /// <summary>
         /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class.
         /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class.
         /// </summary>
         /// </summary>
         /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param>
         /// <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;
             _displayPreferencesManager = displayPreferencesManager;
+            _logger = logger;
         }
         }
 
 
         /// <summary>
         /// <summary>
@@ -61,7 +65,6 @@ namespace Jellyfin.Api.Controllers
             {
             {
                 Client = displayPreferences.Client,
                 Client = displayPreferences.Client,
                 Id = displayPreferences.ItemId.ToString(),
                 Id = displayPreferences.ItemId.ToString(),
-                ViewType = itemPreferences.ViewType.ToString(),
                 SortBy = itemPreferences.SortBy,
                 SortBy = itemPreferences.SortBy,
                 SortOrder = itemPreferences.SortOrder,
                 SortOrder = itemPreferences.SortOrder,
                 IndexBy = displayPreferences.IndexBy?.ToString(),
                 IndexBy = displayPreferences.IndexBy?.ToString(),
@@ -77,11 +80,6 @@ namespace Jellyfin.Api.Controllers
                 dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant();
                 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["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant();
             dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture);
             dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture);
             dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.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)))
             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);
                     displayPreferences.CustomPrefs.Remove(key);
                 }
                 }
             }
             }
@@ -204,11 +201,6 @@ namespace Jellyfin.Api.Controllers
             itemPrefs.RememberSorting = displayPreferences.RememberSorting;
             itemPrefs.RememberSorting = displayPreferences.RememberSorting;
             itemPrefs.ItemId = itemId;
             itemPrefs.ItemId = itemId;
 
 
-            if (Enum.TryParse<ViewType>(displayPreferences.ViewType, true, out var viewType))
-            {
-                itemPrefs.ViewType = viewType;
-            }
-
             // Set all remaining custom preferences.
             // Set all remaining custom preferences.
             _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
             _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
             _displayPreferencesManager.SaveChanges();
             _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))
             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);
             var user = _userManager.GetUserById(userId);
@@ -144,7 +144,7 @@ namespace Jellyfin.Api.Controllers
         {
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             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);
             var user = _userManager.GetUserById(userId);
@@ -190,7 +190,7 @@ namespace Jellyfin.Api.Controllers
         {
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             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);
             var user = _userManager.GetUserById(userId);
@@ -229,7 +229,7 @@ namespace Jellyfin.Api.Controllers
         {
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             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);
             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.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 
 
 namespace Jellyfin.Api.Controllers
 namespace Jellyfin.Api.Controllers
@@ -119,7 +120,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? enableTranscoding,
             [FromQuery] bool? enableTranscoding,
             [FromQuery] bool? allowVideoStreamCopy,
             [FromQuery] bool? allowVideoStreamCopy,
             [FromQuery] bool? allowAudioStreamCopy,
             [FromQuery] bool? allowAudioStreamCopy,
-            [FromBody] PlaybackInfoDto? playbackInfoDto)
+            [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] PlaybackInfoDto? playbackInfoDto)
         {
         {
             var authInfo = _authContext.GetAuthorizationInfo(Request);
             var authInfo = _authContext.GetAuthorizationInfo(Request);
 
 

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

@@ -1,4 +1,5 @@
 using System;
 using System;
+using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Linq;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
@@ -17,6 +18,7 @@ using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
 
 
 namespace Jellyfin.Api.Controllers
 namespace Jellyfin.Api.Controllers
 {
 {
@@ -53,6 +55,13 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// <summary>
         /// Creates a new playlist.
         /// Creates a new playlist.
         /// </summary>
         /// </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>
         /// <param name="createPlaylistRequest">The create playlist payload.</param>
         /// <returns>
         /// <returns>
         /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist.
         /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist.
@@ -61,14 +70,23 @@ namespace Jellyfin.Api.Controllers
         [HttpPost]
         [HttpPost]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
         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
             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);
             }).ConfigureAwait(false);
 
 
             return result;
             return result;

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

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

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

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

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

@@ -133,11 +133,11 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.RequiresElevation)]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult DeleteUser([FromRoute, Required] Guid userId)
+        public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId)
         {
         {
             var user = _userManager.GetUserById(userId);
             var user = _userManager.GetUserById(userId);
             _sessionManager.RevokeUserTokens(user.Id, null);
             _sessionManager.RevokeUserTokens(user.Id, null);
-            _userManager.DeleteUser(userId);
+            await _userManager.DeleteUserAsync(userId).ConfigureAwait(false);
             return NoContent();
             return NoContent();
         }
         }
 
 
@@ -169,7 +169,7 @@ namespace Jellyfin.Api.Controllers
 
 
             if (!string.IsNullOrEmpty(password) && string.IsNullOrEmpty(pw))
             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
             // Password should always be null
@@ -267,11 +267,11 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult> UpdateUserPassword(
         public async Task<ActionResult> UpdateUserPassword(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] Guid userId,
-            [FromBody] UpdateUserPassword request)
+            [FromBody, Required] UpdateUserPassword request)
         {
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             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);
             var user = _userManager.GetUserById(userId);
@@ -296,7 +296,7 @@ namespace Jellyfin.Api.Controllers
 
 
                 if (success == null)
                 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);
                 await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false);
@@ -325,11 +325,11 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult UpdateUserEasyPassword(
         public ActionResult UpdateUserEasyPassword(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] Guid userId,
-            [FromBody] UpdateUserEasyPassword request)
+            [FromBody, Required] UpdateUserEasyPassword request)
         {
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             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);
             var user = _userManager.GetUserById(userId);
@@ -367,16 +367,11 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
         public async Task<ActionResult> UpdateUser(
         public async Task<ActionResult> UpdateUser(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] Guid userId,
-            [FromBody] UserDto updateUser)
+            [FromBody, Required] UserDto updateUser)
         {
         {
-            if (updateUser == null)
-            {
-                return BadRequest();
-            }
-
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false))
             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);
             var user = _userManager.GetUserById(userId);
@@ -407,13 +402,8 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
         public async Task<ActionResult> UpdateUserPolicy(
         public async Task<ActionResult> UpdateUserPolicy(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] Guid userId,
-            [FromBody] UserPolicy newPolicy)
+            [FromBody, Required] UserPolicy newPolicy)
         {
         {
-            if (newPolicy == null)
-            {
-                return BadRequest();
-            }
-
             var user = _userManager.GetUserById(userId);
             var user = _userManager.GetUserById(userId);
 
 
             // If removing admin access
             // If removing admin access
@@ -421,14 +411,14 @@ namespace Jellyfin.Api.Controllers
             {
             {
                 if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
                 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 disabling
             if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator))
             if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator))
             {
             {
-                return Forbid("Administrators cannot be disabled.");
+                return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled.");
             }
             }
 
 
             // If disabling
             // If disabling
@@ -436,7 +426,7 @@ namespace Jellyfin.Api.Controllers
             {
             {
                 if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
                 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;
                 var currentToken = _authContext.GetAuthorizationInfo(Request).Token;
@@ -462,11 +452,11 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
         public async Task<ActionResult> UpdateUserConfiguration(
         public async Task<ActionResult> UpdateUserConfiguration(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] Guid userId,
-            [FromBody] UserConfiguration userConfig)
+            [FromBody, Required] UserConfiguration userConfig)
         {
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false))
             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);
             await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false);
@@ -483,7 +473,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("New")]
         [HttpPost("New")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [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);
             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>
         /// <summary>
         /// Merges videos into a single record.
         /// Merges videos into a single record.
         /// </summary>
         /// </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="204">Videos merged.</response>
         /// <response code="400">Supply at least 2 video ids.</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>
         /// <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)]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status400BadRequest)]
         [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))
                 .Select(i => _libraryManager.GetItemById(i))
                 .OfType<Video>()
                 .OfType<Video>()
                 .OrderBy(i => i.Id)
                 .OrderBy(i => i.Id)

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

@@ -16,7 +16,7 @@
 
 
   <ItemGroup>
   <ItemGroup>
     <PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
     <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.AspNetCore.Mvc" Version="2.2.0" />
     <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
     <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
     <PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
     <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>
         /// <summary>
         /// Gets or sets the user id.
         /// Gets or sets the user id.
         /// </summary>
         /// </summary>
-        public Guid UserId { get; set; }
+        public Guid? UserId { get; set; }
 
 
         /// <summary>
         /// <summary>
         /// Gets or sets the media type.
         /// 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="name">The name.</param>
         /// <param name="type">The type.</param>
         /// <param name="type">The type.</param>
         /// <param name="userId">The user id.</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))
             if (string.IsNullOrEmpty(name))
             {
             {
@@ -34,7 +35,7 @@ namespace Jellyfin.Data.Entities
             Type = type;
             Type = type;
             UserId = userId;
             UserId = userId;
             DateCreated = DateTime.UtcNow;
             DateCreated = DateTime.UtcNow;
-            LogSeverity = LogLevel.Trace;
+            LogSeverity = logLevel;
         }
         }
 
 
         /// <summary>
         /// <summary>

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

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

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

@@ -71,7 +71,7 @@ namespace Jellyfin.Data.Entities
             EnableAutoLogin = false;
             EnableAutoLogin = false;
             PlayDefaultAudioTrack = true;
             PlayDefaultAudioTrack = true;
             SubtitleMode = SubtitlePlaybackMode.Default;
             SubtitleMode = SubtitlePlaybackMode.Default;
-            SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups;
+            SyncPlayAccess = SyncPlayUserAccessType.CreateAndJoinGroups;
 
 
             AddDefaultPermissions();
             AddDefaultPermissions();
             AddDefaultPreferences();
             AddDefaultPreferences();
@@ -326,7 +326,7 @@ namespace Jellyfin.Data.Entities
         /// <summary>
         /// <summary>
         /// Gets or sets the level of sync play permissions this user has.
         /// Gets or sets the level of sync play permissions this user has.
         /// </summary>
         /// </summary>
-        public SyncPlayAccess SyncPlayAccess { get; set; }
+        public SyncPlayUserAccessType SyncPlayAccess { get; set; }
 
 
         /// <summary>
         /// <summary>
         /// Gets or sets the row version.
         /// 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
 namespace Jellyfin.Data.Enums
 {
 {
     /// <summary>
     /// <summary>
-    /// Enum SyncPlayAccess.
+    /// Enum SyncPlayUserAccessType.
     /// </summary>
     /// </summary>
-    public enum SyncPlayAccess
+    public enum SyncPlayUserAccessType
     {
     {
         /// <summary>
         /// <summary>
         /// User can create groups and join them.
         /// 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>
     /// <summary>
     /// An enum representing the type of view for a library or collection.
     /// An enum representing the type of view for a library or collection.
@@ -6,33 +6,108 @@
     public enum ViewType
     public enum ViewType
     {
     {
         /// <summary>
         /// <summary>
-        /// Shows banners.
+        /// Shows albums.
         /// </summary>
         /// </summary>
-        Banner = 0,
+        Albums = 0,
 
 
         /// <summary>
         /// <summary>
-        /// Shows a list of content.
+        /// Shows album artists.
         /// </summary>
         /// </summary>
-        List = 1,
+        AlbumArtists = 1,
 
 
         /// <summary>
         /// <summary>
-        /// Shows poster artwork.
+        /// Shows artists.
         /// </summary>
         /// </summary>
-        Poster = 2,
+        Artists = 2,
 
 
         /// <summary>
         /// <summary>
-        /// Shows poster artwork with a card containing the name and year.
+        /// Shows channels.
         /// </summary>
         /// </summary>
-        PosterCard = 3,
+        Channels = 3,
 
 
         /// <summary>
         /// <summary>
-        /// Shows a thumbnail.
+        /// Shows collections.
         /// </summary>
         /// </summary>
-        Thumb = 4,
+        Collections = 4,
 
 
         /// <summary>
         /// <summary>
-        /// Shows a thumbnail with a card containing the name and year.
+        /// Shows episodes.
         /// </summary>
         /// </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>
 
 
   <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>
 
 
   <ItemGroup>
   <ItemGroup>

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

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

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

@@ -27,6 +27,16 @@ namespace Jellyfin.Networking.Configuration
         /// </summary>
         /// </summary>
         public bool RequireHttps { get; set; }
         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>
         /// <summary>
         /// Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at.
         /// Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at.
         /// </summary>
         /// </summary>
@@ -83,7 +93,7 @@ namespace Jellyfin.Networking.Configuration
         /// </summary>
         /// </summary>
         /// <remarks>
         /// <remarks>
         /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be
         /// 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>
         /// </remarks>
         public bool EnableHttps { get; set; }
         public bool EnableHttps { get; set; }
 
 

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

@@ -1314,9 +1314,7 @@ namespace Jellyfin.Networking.Manager
                 return true;
                 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;
             return false;
         }
         }
     }
     }

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

@@ -27,7 +27,7 @@ namespace Jellyfin.Server.Implementations.Activity
         }
         }
 
 
         /// <inheritdoc/>
         /// <inheritdoc/>
-        public event EventHandler<GenericEventArgs<ActivityLogEntry>> EntryCreated;
+        public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated;
 
 
         /// <inheritdoc/>
         /// <inheritdoc/>
         public async Task CreateAsync(ActivityLog entry)
         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;
             return name;
         }
         }
 
 
-        private static string GetPlaybackNotificationType(string mediaType)
+        private static string? GetPlaybackNotificationType(string mediaType)
         {
         {
             if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
             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;
             return name;
         }
         }
 
 
-        private static string GetPlaybackStoppedNotificationType(string mediaType)
+        private static string? GetPlaybackStoppedNotificationType(string mediaType)
         {
         {
             if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
             if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
             {
             {

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

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

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

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

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

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

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

@@ -1,5 +1,3 @@
-#nullable enable
-
 using System;
 using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.IO;
 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 System.Threading.Tasks;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities;

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

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

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

@@ -13,9 +13,9 @@ namespace Jellyfin.Server.Implementations.ValueConverters
         /// </summary>
         /// </summary>
         /// <param name="kind">The kind to specify.</param>
         /// <param name="kind">The kind to specify.</param>
         /// <param name="mappingHints">The mapping hints.</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)
             : 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>();
             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.Filters;
 using Jellyfin.Server.Formatters;
 using Jellyfin.Server.Formatters;
 using MediaBrowser.Common.Json;
 using MediaBrowser.Common.Json;
+using MediaBrowser.Common.Net;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Entities;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Authorization;
@@ -127,18 +128,32 @@ namespace Jellyfin.Server.Extensions
                         policy.AddRequirements(new RequiresElevationRequirement());
                         policy.AddRequirements(new RequiresElevationRequirement());
                     });
                     });
                 options.AddPolicy(
                 options.AddPolicy(
-                    Policies.SyncPlayAccess,
+                    Policies.SyncPlayHasAccess,
                     policy =>
                     policy =>
                     {
                     {
                         policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
                         policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
-                        policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.JoinGroups));
+                        policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.HasAccess));
                     });
                     });
                 options.AddPolicy(
                 options.AddPolicy(
-                    Policies.SyncPlayCreateGroupAccess,
+                    Policies.SyncPlayCreateGroup,
                     policy =>
                     policy =>
                     {
                     {
                         policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
                         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 =>
                 .Configure<ForwardedHeadersOptions>(options =>
                 {
                 {
                     options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
                     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
             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="CommandLineParser" Version="2.8.0" />
     <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="5.0.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.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" Version="4.0.0" />
     <PackageReference Include="prometheus-net.AspNetCore" Version="4.0.0" />
     <PackageReference Include="prometheus-net.AspNetCore" Version="4.0.0" />
     <PackageReference Include="Serilog.AspNetCore" Version="3.4.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 }
                 { "unstable", ChromecastVersion.Unstable }
             };
             };
 
 
+            var customDisplayPrefs = new HashSet<string>();
             var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
             var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
             using (var connection = SQLite3.Open(dbFilePath, ConnectionFlags.ReadOnly, null))
             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)
                     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);
                     dbContext.Add(displayPreferences);

+ 4 - 0
Jellyfin.Server/Startup.cs

@@ -128,6 +128,8 @@ namespace Jellyfin.Server
                     mainApp.UseHttpsRedirection();
                     mainApp.UseHttpsRedirection();
                 }
                 }
 
 
+                // This must be injected before any path related middleware.
+                mainApp.UsePathTrim();
                 mainApp.UseStaticFiles();
                 mainApp.UseStaticFiles();
                 if (appConfig.HostWebClient())
                 if (appConfig.HostWebClient())
                 {
                 {
@@ -142,6 +144,8 @@ namespace Jellyfin.Server
                         RequestPath = "/web",
                         RequestPath = "/web",
                         ContentTypeProvider = extensionProvider
                         ContentTypeProvider = extensionProvider
                     });
                     });
+
+                    mainApp.UseRobotsRedirection();
                 }
                 }
 
 
                 mainApp.UseAuthentication();
                 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;
+using System.Globalization;
 using System.Text.Json;
 using System.Text.Json;
 using System.Text.Json.Serialization;
 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)
         public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
         {
         {
             var guidStr = reader.GetString();
             var guidStr = reader.GetString();
-
             return guidStr == null ? Guid.Empty : new Guid(guidStr);
             return guidStr == null ? Guid.Empty : new Guid(guidStr);
         }
         }
 
 
         /// <inheritdoc />
         /// <inheritdoc />
         public override void Write(Utf8JsonWriter writer, Guid value, JsonSerializerOptions options)
         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 JsonVersionConverter());
             options.Converters.Add(new JsonStringEnumConverter());
             options.Converters.Add(new JsonStringEnumConverter());
             options.Converters.Add(new JsonNullableStructConverterFactory());
             options.Converters.Add(new JsonNullableStructConverterFactory());
+            options.Converters.Add(new JsonBoolNumberConverter());
 
 
             return options;
             return options;
         }
         }

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

@@ -48,10 +48,10 @@ namespace MediaBrowser.Controller.BaseItemManager
                 return !baseItem.EnableMediaSourceDisplay;
                 return !baseItem.EnableMediaSourceDisplay;
             }
             }
 
 
-            var typeOptions = libraryOptions.GetTypeOptions(GetType().Name);
+            var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name);
             if (typeOptions != null)
             if (typeOptions != null)
             {
             {
-                return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
+                return typeOptions.MetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
             }
             }
 
 
             if (!libraryOptions.EnableInternetProviders)
             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));
             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 />
         /// <inheritdoc />
@@ -79,7 +79,7 @@ namespace MediaBrowser.Controller.BaseItemManager
                 return !baseItem.EnableMediaSourceDisplay;
                 return !baseItem.EnableMediaSourceDisplay;
             }
             }
 
 
-            var typeOptions = libraryOptions.GetTypeOptions(GetType().Name);
+            var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name);
             if (typeOptions != null)
             if (typeOptions != null)
             {
             {
                 return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
                 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>();
                         new List<FileSystemMetadata>();
 
 
                     var ownedItemsChanged = await RefreshedOwnedItems(options, files, cancellationToken).ConfigureAwait(false);
                     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)
                     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);
                             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;
                         continue;
                     }
                     }

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

@@ -571,7 +571,7 @@ namespace MediaBrowser.Controller.Library
             string videoPath,
             string videoPath,
             string[] files);
             string[] files);
 
 
-        void RunMetadataSavers(IReadOnlyList<BaseItem> items, ItemUpdateType updateReason);
+        Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason);
 
 
         BaseItem GetParentItem(string parentId, Guid? userId);
         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.
         /// Deletes the specified user.
         /// </summary>
         /// </summary>
         /// <param name="userId">The id of the user to be deleted.</param>
         /// <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>
         /// <summary>
         /// Resets the password.
         /// Resets the password.

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

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

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

@@ -51,5 +51,12 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <param name="request">The request.</param>
         /// <param name="request">The request.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken);
         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.
             // 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);
             var enableThumbnail = useIFrame && !string.Equals("wtv", container, StringComparison.OrdinalIgnoreCase);
             if (enableThumbnail)
             if (enableThumbnail)
             {
             {
+                var useLargerBatchSize = string.Equals("mpegts", container, StringComparison.OrdinalIgnoreCase);
+                var batchSize = useLargerBatchSize ? "50" : "24";
                 if (string.IsNullOrEmpty(vf))
                 if (string.IsNullOrEmpty(vf))
                 {
                 {
-                    vf = "-vf thumbnail=24";
+                    vf = "-vf thumbnail=" + batchSize;
                 }
                 }
                 else
                 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.
             // The left side of the dot is the platform number, and the right side is the device number on the platform.
             OpenclDevice = "0.0";
             OpenclDevice = "0.0";
             EnableTonemapping = false;
             EnableTonemapping = false;
-            TonemappingAlgorithm = "reinhard";
+            TonemappingAlgorithm = "hable";
             TonemappingRange = "auto";
             TonemappingRange = "auto";
             TonemappingDesat = 0;
             TonemappingDesat = 0;
             TonemappingThreshold = 0.8;
             TonemappingThreshold = 0.8;
-            TonemappingPeak = 0;
+            TonemappingPeak = 100;
             TonemappingParam = 0;
             TonemappingParam = 0;
             H264Crf = 23;
             H264Crf = 23;
             H265Crf = 28;
             H265Crf = 28;

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

@@ -152,7 +152,7 @@ namespace MediaBrowser.Model.Dto
         /// Gets or sets the channel identifier.
         /// Gets or sets the channel identifier.
         /// </summary>
         /// </summary>
         /// <value>The channel identifier.</value>
         /// <value>The channel identifier.</value>
-        public Guid ChannelId { get; set; }
+        public Guid? ChannelId { get; set; }
 
 
         public string ChannelName { get; set; }
         public string ChannelName { get; set; }
 
 
@@ -270,7 +270,7 @@ namespace MediaBrowser.Model.Dto
         /// Gets or sets the parent id.
         /// Gets or sets the parent id.
         /// </summary>
         /// </summary>
         /// <value>The parent id.</value>
         /// <value>The parent id.</value>
-        public Guid ParentId { get; set; }
+        public Guid? ParentId { get; set; }
 
 
         /// <summary>
         /// <summary>
         /// Gets or sets the type.
         /// Gets or sets the type.
@@ -344,13 +344,13 @@ namespace MediaBrowser.Model.Dto
         /// Gets or sets the series id.
         /// Gets or sets the series id.
         /// </summary>
         /// </summary>
         /// <value>The series id.</value>
         /// <value>The series id.</value>
-        public Guid SeriesId { get; set; }
+        public Guid? SeriesId { get; set; }
 
 
         /// <summary>
         /// <summary>
         /// Gets or sets the season identifier.
         /// Gets or sets the season identifier.
         /// </summary>
         /// </summary>
         /// <value>The season identifier.</value>
         /// <value>The season identifier.</value>
-        public Guid SeasonId { get; set; }
+        public Guid? SeasonId { get; set; }
 
 
         /// <summary>
         /// <summary>
         /// Gets or sets the special feature count.
         /// Gets or sets the special feature count.
@@ -428,7 +428,7 @@ namespace MediaBrowser.Model.Dto
         /// Gets or sets the album id.
         /// Gets or sets the album id.
         /// </summary>
         /// </summary>
         /// <value>The album id.</value>
         /// <value>The album id.</value>
-        public Guid AlbumId { get; set; }
+        public Guid? AlbumId { get; set; }
 
 
         /// <summary>
         /// <summary>
         /// Gets or sets the album image tag.
         /// 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.
         /// Gets or sets a value indicating what SyncPlay features the user can access.
         /// </summary>
         /// </summary>
         /// <value>Access level to SyncPlay features.</value>
         /// <value>Access level to SyncPlay features.</value>
-        public SyncPlayAccess SyncPlayAccess { get; set; }
+        public SyncPlayUserAccessType SyncPlayAccess { get; set; }
 
 
         public UserPolicy()
         public UserPolicy()
         {
         {
@@ -160,7 +160,7 @@ namespace MediaBrowser.Model.Users
             EnableContentDownloading = true;
             EnableContentDownloading = true;
             EnablePublicSharing = true;
             EnablePublicSharing = true;
             EnableRemoteAccess = 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);
             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>();
             var personsToSave = new List<BaseItem>();
 
 
@@ -239,6 +239,7 @@ namespace MediaBrowser.Providers.Manager
 
 
                 if (person.ProviderIds.Count > 0 || !string.IsNullOrWhiteSpace(person.ImageUrl))
                 if (person.ProviderIds.Count > 0 || !string.IsNullOrWhiteSpace(person.ImageUrl))
                 {
                 {
+                    var itemUpdateType = ItemUpdateType.MetadataDownload;
                     var saveEntity = false;
                     var saveEntity = false;
                     var personEntity = LibraryManager.GetPerson(person.Name);
                     var personEntity = LibraryManager.GetPerson(person.Name);
                     foreach (var id in person.ProviderIds)
                     foreach (var id in person.ProviderIds)
@@ -261,18 +262,18 @@ namespace MediaBrowser.Providers.Manager
                             0);
                             0);
 
 
                         saveEntity = true;
                         saveEntity = true;
+                        itemUpdateType = ItemUpdateType.ImageUpdate;
                     }
                     }
 
 
                     if (saveEntity)
                     if (saveEntity)
                     {
                     {
                         personsToSave.Add(personEntity);
                         personsToSave.Add(personEntity);
+                        await LibraryManager.RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
                     }
                     }
                 }
                 }
             }
             }
 
 
-            LibraryManager.RunMetadataSavers(personsToSave, ItemUpdateType.MetadataDownload);
             LibraryManager.CreateItems(personsToSave, null, CancellationToken.None);
             LibraryManager.CreateItems(personsToSave, null, CancellationToken.None);
-            return Task.CompletedTask;
         }
         }
 
 
         protected virtual Task AfterMetadataRefresh(TItemType item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
         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
                 var person = new PersonInfo
                 {
                 {
-                    Name = result.Director.Trim(),
+                    Name = result.Writer.Trim(),
                     Type = PersonType.Writer
                     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)
 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`
 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
 ### Running The Server
 
 
 The following instructions will help you get the project up and running via the command line, or your preferred IDE.
 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
 ```bash
 cd jellyfin                          # Move into the repository directory
 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.
 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
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
 # 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 \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
  && 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
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
 # 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 \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
  && 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
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
 # 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 \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
  && 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
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
 # 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 \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
  && 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
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
 # 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 \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
  && 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
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
 # 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 \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
  && 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
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
 # 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 \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
  && 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
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
 # 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 \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
  && 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
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
 # 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 \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
  && 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
 # Install dotnet repository
 # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
 # 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 \
  && mkdir -p dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
  && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
  && 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" Version="4.14.0" />
     <PackageReference Include="AutoFixture.AutoMoq" Version="4.14.0" />
     <PackageReference Include="AutoFixture.AutoMoq" Version="4.14.0" />
     <PackageReference Include="AutoFixture.Xunit2" 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.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" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />

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

@@ -13,7 +13,7 @@
   </PropertyGroup>
   </PropertyGroup>
 
 
   <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" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />
     <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;
+using System.Globalization;
 using System.Text.Json;
 using System.Text.Json;
 using MediaBrowser.Common.Json.Converters;
 using MediaBrowser.Common.Json.Converters;
 using Xunit;
 using Xunit;
 
 
-namespace Jellyfin.Common.Tests.Extensions
+namespace Jellyfin.Common.Tests.Json
 {
 {
     public class JsonGuidConverterTests
     public class JsonGuidConverterTests
     {
     {
@@ -44,9 +45,25 @@ namespace Jellyfin.Common.Tests.Extensions
         }
         }
 
 
         [Fact]
         [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>
   </PropertyGroup>
 
 
   <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" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />

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

@@ -8,7 +8,7 @@
   </PropertyGroup>
   </PropertyGroup>
 
 
   <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" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />

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

@@ -19,7 +19,7 @@
   </ItemGroup>
   </ItemGroup>
 
 
   <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" Version="2.4.1" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />
     <PackageReference Include="coverlet.collector" Version="1.3.0" />

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików