ソースを参照

Merge branch 'master' into network-rewrite

Shadowghost 2 年 前
コミット
c042f20224

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

@@ -164,7 +164,7 @@ namespace Emby.Dlna.Eventing
 
             try
             {
-                using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+                using var response = await _httpClientFactory.CreateClient(NamedClient.DirectIp)
                     .SendAsync(options, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
             }
             catch (OperationCanceledException)

+ 0 - 13
Emby.Server.Implementations/IO/ExtendedFileSystemInfo.cs

@@ -1,13 +0,0 @@
-#pragma warning disable CS1591
-
-namespace Emby.Server.Implementations.IO
-{
-    public class ExtendedFileSystemInfo
-    {
-        public bool IsHidden { get; set; }
-
-        public bool IsReadOnly { get; set; }
-
-        public bool Exists { get; set; }
-    }
-}

+ 11 - 35
Emby.Server.Implementations/IO/ManagedFileSystem.cs

@@ -267,25 +267,6 @@ namespace Emby.Server.Implementations.IO
             return result;
         }
 
-        private static ExtendedFileSystemInfo GetExtendedFileSystemInfo(string path)
-        {
-            var result = new ExtendedFileSystemInfo();
-
-            var info = new FileInfo(path);
-
-            if (info.Exists)
-            {
-                result.Exists = true;
-
-                var attributes = info.Attributes;
-
-                result.IsHidden = (attributes & FileAttributes.Hidden) == FileAttributes.Hidden;
-                result.IsReadOnly = (attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly;
-            }
-
-            return result;
-        }
-
         /// <summary>
         /// Takes a filename and removes invalid characters.
         /// </summary>
@@ -403,19 +384,18 @@ namespace Emby.Server.Implementations.IO
                 return;
             }
 
-            var info = GetExtendedFileSystemInfo(path);
+            var info = new FileInfo(path);
 
-            if (info.Exists && info.IsHidden != isHidden)
+            if (info.Exists &&
+                ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) != isHidden)
             {
                 if (isHidden)
                 {
-                    File.SetAttributes(path, File.GetAttributes(path) | FileAttributes.Hidden);
+                    File.SetAttributes(path, info.Attributes | FileAttributes.Hidden);
                 }
                 else
                 {
-                    var attributes = File.GetAttributes(path);
-                    attributes = RemoveAttribute(attributes, FileAttributes.Hidden);
-                    File.SetAttributes(path, attributes);
+                    File.SetAttributes(path, info.Attributes & ~FileAttributes.Hidden);
                 }
             }
         }
@@ -428,19 +408,20 @@ namespace Emby.Server.Implementations.IO
                 return;
             }
 
-            var info = GetExtendedFileSystemInfo(path);
+            var info = new FileInfo(path);
 
             if (!info.Exists)
             {
                 return;
             }
 
-            if (info.IsReadOnly == readOnly && info.IsHidden == isHidden)
+            if (((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) == readOnly
+                && ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) == isHidden)
             {
                 return;
             }
 
-            var attributes = File.GetAttributes(path);
+            var attributes = info.Attributes;
 
             if (readOnly)
             {
@@ -448,7 +429,7 @@ namespace Emby.Server.Implementations.IO
             }
             else
             {
-                attributes = RemoveAttribute(attributes, FileAttributes.ReadOnly);
+                attributes &= ~FileAttributes.ReadOnly;
             }
 
             if (isHidden)
@@ -457,17 +438,12 @@ namespace Emby.Server.Implementations.IO
             }
             else
             {
-                attributes = RemoveAttribute(attributes, FileAttributes.Hidden);
+                attributes &= ~FileAttributes.Hidden;
             }
 
             File.SetAttributes(path, attributes);
         }
 
-        private static FileAttributes RemoveAttribute(FileAttributes attributes, FileAttributes attributesToRemove)
-        {
-            return attributes & ~attributesToRemove;
-        }
-
         /// <summary>
         /// Swaps the files.
         /// </summary>

+ 28 - 0
Emby.Server.Implementations/Localization/Core/sn.json

@@ -0,0 +1,28 @@
+{
+    "HeaderAlbumArtists": "Vaimbi vemadambarefu",
+    "HeaderContinueWatching": "Simudzira kuona",
+    "HeaderFavoriteSongs": "Nziyo dzaunofarira",
+    "Albums": "Dambarefu",
+    "AppDeviceValues": "Apu: {0}, Dhivhaisi: {1}",
+    "Application": "Purogiramu",
+    "Artists": "Vaimbi",
+    "AuthenticationSucceededWithUserName": "apinda",
+    "Books": "Mabhuku",
+    "CameraImageUploadedFrom": "Mufananidzo mutsva vabva pakamera {0}",
+    "Channels": "Machanewo",
+    "ChapterNameValue": "Chikamu {0}",
+    "Collections": "Akafanana",
+    "Default": "Zvakasarudzwa Kare",
+    "DeviceOfflineWithName": "{0} haasisipo",
+    "DeviceOnlineWithName": "{0} aripo",
+    "External": "Zvekunze",
+    "FailedLoginAttemptWithUserName": "Vatadza kuloga chimboedza kushandisa {0}",
+    "Favorites": "Zvaunofarira",
+    "Folders": "Mafoodha",
+    "Forced": "Zvekumanikidzira",
+    "Genres": "Mhando",
+    "HeaderFavoriteAlbums": "Madambarefu aunofarira",
+    "HeaderFavoriteArtists": "Vaimbi vaunofarira",
+    "HeaderFavoriteEpisodes": "Maepisodhi aunofarira",
+    "HeaderFavoriteShows": "Masirisi aunofarira"
+}

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

@@ -15,7 +15,7 @@
     "Favorites": "我的最愛",
     "Folders": "資料夾",
     "Genres": "風格",
-    "HeaderAlbumArtists": "專輯藝人",
+    "HeaderAlbumArtists": "專輯歌手",
     "HeaderContinueWatching": "繼續觀看",
     "HeaderFavoriteAlbums": "最愛的專輯",
     "HeaderFavoriteArtists": "最愛的藝人",

+ 10 - 2
Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs

@@ -38,7 +38,15 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy
                 return Task.CompletedTask;
             }
 
-            if (requirement.RequireAdmin && !context.User.IsInRole(UserRoles.Administrator))
+            var contextUser = context.User;
+            if (requirement.RequireAdmin && !contextUser.IsInRole(UserRoles.Administrator))
+            {
+                context.Fail();
+                return Task.CompletedTask;
+            }
+
+            var userId = contextUser.GetUserId();
+            if (userId.Equals(default))
             {
                 context.Fail();
                 return Task.CompletedTask;
@@ -50,7 +58,7 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy
                 return Task.CompletedTask;
             }
 
-            var user = _userManager.GetUserById(context.User.GetUserId());
+            var user = _userManager.GetUserById(userId);
             if (user is null)
             {
                 throw new ResourceNotFoundException();

+ 12 - 0
Jellyfin.Api/Controllers/SystemController.cs

@@ -59,10 +59,12 @@ public class SystemController : BaseJellyfinApiController
     /// Gets information about the server.
     /// </summary>
     /// <response code="200">Information retrieved.</response>
+    /// <response code="403">User does not have permission to retrieve information.</response>
     /// <returns>A <see cref="SystemInfo"/> with info about the system.</returns>
     [HttpGet("Info")]
     [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)]
     [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public ActionResult<SystemInfo> GetSystemInfo()
     {
         return _appHost.GetSystemInfo(Request);
@@ -97,10 +99,12 @@ public class SystemController : BaseJellyfinApiController
     /// Restarts the application.
     /// </summary>
     /// <response code="204">Server restarted.</response>
+    /// <response code="403">User does not have permission to restart server.</response>
     /// <returns>No content. Server restarted.</returns>
     [HttpPost("Restart")]
     [Authorize(Policy = Policies.LocalAccessOrRequiresElevation)]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public ActionResult RestartApplication()
     {
         Task.Run(async () =>
@@ -115,10 +119,12 @@ public class SystemController : BaseJellyfinApiController
     /// Shuts down the application.
     /// </summary>
     /// <response code="204">Server shut down.</response>
+    /// <response code="403">User does not have permission to shutdown server.</response>
     /// <returns>No content. Server shut down.</returns>
     [HttpPost("Shutdown")]
     [Authorize(Policy = Policies.RequiresElevation)]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public ActionResult ShutdownApplication()
     {
         Task.Run(async () =>
@@ -133,10 +139,12 @@ public class SystemController : BaseJellyfinApiController
     /// Gets a list of available server log files.
     /// </summary>
     /// <response code="200">Information retrieved.</response>
+    /// <response code="403">User does not have permission to get server logs.</response>
     /// <returns>An array of <see cref="LogFile"/> with the available log files.</returns>
     [HttpGet("Logs")]
     [Authorize(Policy = Policies.RequiresElevation)]
     [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public ActionResult<LogFile[]> GetServerLogs()
     {
         IEnumerable<FileSystemMetadata> files;
@@ -170,10 +178,12 @@ public class SystemController : BaseJellyfinApiController
     /// Gets information about the request endpoint.
     /// </summary>
     /// <response code="200">Information retrieved.</response>
+    /// <response code="403">User does not have permission to get endpoint information.</response>
     /// <returns><see cref="EndPointInfo"/> with information about the endpoint.</returns>
     [HttpGet("Endpoint")]
     [Authorize]
     [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public ActionResult<EndPointInfo> GetEndpointInfo()
     {
         return new EndPointInfo
@@ -188,10 +198,12 @@ public class SystemController : BaseJellyfinApiController
     /// </summary>
     /// <param name="name">The name of the log file to get.</param>
     /// <response code="200">Log file retrieved.</response>
+    /// <response code="403">User does not have permission to get log files.</response>
     /// <returns>The log file.</returns>
     [HttpGet("Logs/Log")]
     [Authorize(Policy = Policies.RequiresElevation)]
     [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
     [ProducesFile(MediaTypeNames.Text.Plain)]
     public ActionResult GetLogFile([FromQuery, Required] string name)
     {

+ 120 - 0
Jellyfin.Networking/HappyEyeballs/HttpClientExtension.cs

@@ -0,0 +1,120 @@
+/*
+The MIT License (MIT)
+
+Copyright (c) .NET Foundation and Contributors
+
+All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+using System.IO;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Jellyfin.Networking.HappyEyeballs
+{
+    /// <summary>
+    /// Defines the <see cref="HttpClientExtension"/> class.
+    ///
+    /// Implementation taken from https://github.com/ppy/osu-framework/pull/4191 .
+    /// </summary>
+    public static class HttpClientExtension
+    {
+        /// <summary>
+        /// Gets or sets a value indicating whether the client should use IPv6.
+        /// </summary>
+        public static bool UseIPv6 { get; set; } = true;
+
+        /// <summary>
+        /// Implements the httpclient callback method.
+        /// </summary>
+        /// <param name="context">The <see cref="SocketsHttpConnectionContext"/> instance.</param>
+        /// <param name="cancellationToken">The <see cref="CancellationToken"/> instance.</param>
+        /// <returns>The http steam.</returns>
+        public static async ValueTask<Stream> OnConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
+        {
+            if (!UseIPv6)
+            {
+                return await AttemptConnection(AddressFamily.InterNetwork, context, cancellationToken).ConfigureAwait(false);
+            }
+
+            using var cancelIPv6 = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            var tryConnectAsyncIPv6 = AttemptConnection(AddressFamily.InterNetworkV6, context, cancelIPv6.Token);
+
+            // GetAwaiter().GetResult() is used instead of .Result as this results in improved exception handling.
+            // The tasks have already been completed.
+            // See https://github.com/dotnet/corefx/pull/29792/files#r189415885 for more details.
+            if (await Task.WhenAny(tryConnectAsyncIPv6, Task.Delay(200, cancelIPv6.Token)).ConfigureAwait(false) == tryConnectAsyncIPv6 && tryConnectAsyncIPv6.IsCompletedSuccessfully)
+            {
+                cancelIPv6.Cancel();
+                return tryConnectAsyncIPv6.GetAwaiter().GetResult();
+            }
+
+            using var cancelIPv4 = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            var tryConnectAsyncIPv4 = AttemptConnection(AddressFamily.InterNetwork, context, cancelIPv4.Token);
+
+            if (await Task.WhenAny(tryConnectAsyncIPv6, tryConnectAsyncIPv4).ConfigureAwait(false) == tryConnectAsyncIPv6)
+            {
+                if (tryConnectAsyncIPv6.IsCompletedSuccessfully)
+                {
+                    cancelIPv4.Cancel();
+                    return tryConnectAsyncIPv6.GetAwaiter().GetResult();
+                }
+
+                return tryConnectAsyncIPv4.GetAwaiter().GetResult();
+            }
+            else
+            {
+                if (tryConnectAsyncIPv4.IsCompletedSuccessfully)
+                {
+                    cancelIPv6.Cancel();
+                    return tryConnectAsyncIPv4.GetAwaiter().GetResult();
+                }
+
+                return tryConnectAsyncIPv6.GetAwaiter().GetResult();
+            }
+        }
+
+        private static async Task<Stream> AttemptConnection(AddressFamily addressFamily, SocketsHttpConnectionContext context, CancellationToken cancellationToken)
+        {
+            // The following socket constructor will create a dual-mode socket on systems where IPV6 is available.
+            var socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp)
+            {
+                // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios.
+                NoDelay = true
+            };
+
+            try
+            {
+                await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false);
+                // The stream should take the ownership of the underlying socket,
+                // closing it when it's disposed.
+                return new NetworkStream(socket, ownsSocket: true);
+            }
+            catch
+            {
+                socket.Dispose();
+                throw;
+            }
+        }
+    }
+}

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

@@ -184,9 +184,16 @@ namespace Jellyfin.Networking.Manager
             {
                 Thread.Sleep(2000);
                 var networkConfig = _configurationManager.GetNetworkConfiguration();
-                InitialiseLan(networkConfig);
-                InitialiseInterfaces();
-                EnforceBindSettings(networkConfig);
+                if (IsIPv6Enabled && !Socket.OSSupportsIPv6)
+                {
+                    UpdateSettings(networkConfig);
+                }
+                else
+                {
+                    InitialiseInterfaces();
+                    InitialiseLan(networkConfig);
+                    EnforceBindSettings(networkConfig);
+                }
 
                 NetworkChanged?.Invoke(this, EventArgs.Empty);
             }
@@ -519,6 +526,7 @@ namespace Jellyfin.Networking.Manager
             ArgumentNullException.ThrowIfNull(configuration);
 
             var config = (NetworkConfiguration)configuration;
+            HappyEyeballs.HttpClientExtension.UseIPv6 = config.EnableIPv6;
 
             InitialiseLan(config);
             InitialiseRemote(config);

+ 3 - 3
Jellyfin.Server/Migrations/MigrationRunner.cs

@@ -22,8 +22,7 @@ namespace Jellyfin.Server.Migrations
         private static readonly Type[] _preStartupMigrationTypes =
         {
             typeof(PreStartupRoutines.CreateNetworkConfiguration),
-            typeof(PreStartupRoutines.MigrateMusicBrainzTimeout),
-            typeof(PreStartupRoutines.MigrateRatingLevels)
+            typeof(PreStartupRoutines.MigrateMusicBrainzTimeout)
         };
 
         /// <summary>
@@ -41,7 +40,8 @@ namespace Jellyfin.Server.Migrations
             typeof(Routines.MigrateDisplayPreferencesDb),
             typeof(Routines.RemoveDownloadImagesInAdvance),
             typeof(Routines.MigrateAuthenticationDb),
-            typeof(Routines.FixPlaylistOwner)
+            typeof(Routines.FixPlaylistOwner),
+            typeof(Routines.MigrateRatingLevels)
         };
 
         /// <summary>

+ 0 - 86
Jellyfin.Server/Migrations/PreStartupRoutines/MigrateRatingLevels.cs

@@ -1,86 +0,0 @@
-using System;
-using System.Globalization;
-using System.IO;
-
-using Emby.Server.Implementations;
-using MediaBrowser.Controller;
-using Microsoft.Extensions.Logging;
-using SQLitePCL.pretty;
-
-namespace Jellyfin.Server.Migrations.PreStartupRoutines
-{
-    /// <summary>
-    /// Migrate rating levels to new rating level system.
-    /// </summary>
-    internal class MigrateRatingLevels : IMigrationRoutine
-    {
-        private const string DbFilename = "library.db";
-        private readonly ILogger<MigrateRatingLevels> _logger;
-        private readonly IServerApplicationPaths _applicationPaths;
-
-        public MigrateRatingLevels(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory)
-        {
-            _applicationPaths = applicationPaths;
-            _logger = loggerFactory.CreateLogger<MigrateRatingLevels>();
-        }
-
-        /// <inheritdoc/>
-        public Guid Id => Guid.Parse("{67445D54-B895-4B24-9F4C-35CE0690EA07}");
-
-        /// <inheritdoc/>
-        public string Name => "MigrateRatingLevels";
-
-        /// <inheritdoc/>
-        public bool PerformOnNewInstall => false;
-
-        /// <inheritdoc/>
-        public void Perform()
-        {
-            var dataPath = _applicationPaths.DataPath;
-            var dbPath = Path.Combine(dataPath, DbFilename);
-            using (var connection = SQLite3.Open(
-                dbPath,
-                ConnectionFlags.ReadWrite,
-                null))
-            {
-                // Back up the database before deleting any entries
-                for (int i = 1; ; i++)
-                {
-                    var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
-                    if (!File.Exists(bakPath))
-                    {
-                        try
-                        {
-                            File.Copy(dbPath, bakPath);
-                            _logger.LogInformation("Library database backed up to {BackupPath}", bakPath);
-                            break;
-                        }
-                        catch (Exception ex)
-                        {
-                            _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
-                            throw;
-                        }
-                    }
-                }
-
-                // Migrate parental rating levels to new schema
-                _logger.LogInformation("Migrating parental rating levels.");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE OfficialRating = 'NR'");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE InheritedParentalRatingValue = ''");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE InheritedParentalRatingValue = 0");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 1000 WHERE InheritedParentalRatingValue = 100");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 1000 WHERE InheritedParentalRatingValue = 15");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 18 WHERE InheritedParentalRatingValue = 10");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 18 WHERE InheritedParentalRatingValue = 9");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 16 WHERE InheritedParentalRatingValue = 8");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 12 WHERE InheritedParentalRatingValue = 7");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 12 WHERE InheritedParentalRatingValue = 6");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 12 WHERE InheritedParentalRatingValue = 5");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 7 WHERE InheritedParentalRatingValue = 4");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 6 WHERE InheritedParentalRatingValue = 3");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 6 WHERE InheritedParentalRatingValue = 2");
-                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 0 WHERE InheritedParentalRatingValue = 1");
-            }
-        }
-    }
-}

+ 103 - 0
Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs

@@ -0,0 +1,103 @@
+using System;
+using System.Globalization;
+using System.IO;
+
+using Emby.Server.Implementations.Data;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Globalization;
+using Microsoft.Extensions.Logging;
+using SQLitePCL.pretty;
+
+namespace Jellyfin.Server.Migrations.Routines
+{
+    /// <summary>
+    /// Migrate rating levels to new rating level system.
+    /// </summary>
+    internal class MigrateRatingLevels : IMigrationRoutine
+    {
+        private const string DbFilename = "library.db";
+        private readonly ILogger<MigrateRatingLevels> _logger;
+        private readonly IServerApplicationPaths _applicationPaths;
+        private readonly ILocalizationManager _localizationManager;
+        private readonly IItemRepository _repository;
+
+        public MigrateRatingLevels(
+            IServerApplicationPaths applicationPaths,
+            ILoggerFactory loggerFactory,
+            ILocalizationManager localizationManager,
+            IItemRepository repository)
+        {
+            _applicationPaths = applicationPaths;
+            _localizationManager = localizationManager;
+            _repository = repository;
+            _logger = loggerFactory.CreateLogger<MigrateRatingLevels>();
+        }
+
+        /// <inheritdoc/>
+        public Guid Id => Guid.Parse("{67445D54-B895-4B24-9F4C-35CE0690EA07}");
+
+        /// <inheritdoc/>
+        public string Name => "MigrateRatingLevels";
+
+        /// <inheritdoc/>
+        public bool PerformOnNewInstall => false;
+
+        /// <inheritdoc/>
+        public void Perform()
+        {
+            var dbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
+
+            // Back up the database before modifying any entries
+            for (int i = 1; ; i++)
+            {
+                var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
+                if (!File.Exists(bakPath))
+                {
+                    try
+                    {
+                        File.Copy(dbPath, bakPath);
+                        _logger.LogInformation("Library database backed up to {BackupPath}", bakPath);
+                        break;
+                    }
+                    catch (Exception ex)
+                    {
+                        _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
+                        throw;
+                    }
+                }
+            }
+
+            // Migrate parental rating strings to new levels
+            _logger.LogInformation("Recalculating parental rating levels based on rating string.");
+            using (var connection = SQLite3.Open(
+                dbPath,
+                ConnectionFlags.ReadWrite,
+                null))
+            {
+                var queryResult = connection.Query("SELECT DISTINCT OfficialRating FROM TypedBaseItems");
+                foreach (var entry in queryResult)
+                {
+                    var ratingString = entry[0].ToString();
+                    if (string.IsNullOrEmpty(ratingString))
+                    {
+                        connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE OfficialRating IS NULL OR OfficialRating='';");
+                    }
+                    else
+                    {
+                        var ratingValue = _localizationManager.GetRatingLevel(ratingString).ToString();
+                        if (string.IsNullOrEmpty(ratingValue))
+                        {
+                            ratingValue = "NULL";
+                        }
+
+                        var statement = connection.PrepareStatement("UPDATE TypedBaseItems SET InheritedParentalRatingValue = @Value WHERE OfficialRating = @Rating;");
+                        statement.TryBind("@Value", ratingValue);
+                        statement.TryBind("@Rating", ratingString);
+                        statement.ExecuteQuery();
+                    }
+                }
+            }
+        }
+    }
+}

+ 19 - 1
Jellyfin.Server/Startup.cs

@@ -8,6 +8,7 @@ using System.Text;
 using Jellyfin.Api.Middleware;
 using Jellyfin.MediaEncoding.Hls.Extensions;
 using Jellyfin.Networking.Configuration;
+using Jellyfin.Networking.HappyEyeballs;
 using Jellyfin.Server.Extensions;
 using Jellyfin.Server.HealthChecks;
 using Jellyfin.Server.Implementations;
@@ -26,6 +27,7 @@ using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.FileProviders;
 using Microsoft.Extensions.Hosting;
+using Microsoft.VisualBasic;
 using Prometheus;
 
 namespace Jellyfin.Server
@@ -78,6 +80,13 @@ namespace Jellyfin.Server
             var acceptJsonHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json, 1.0);
             var acceptXmlHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml, 0.9);
             var acceptAnyHeader = new MediaTypeWithQualityHeaderValue("*/*", 0.8);
+            Func<IServiceProvider, HttpMessageHandler> eyeballsHttpClientHandlerDelegate = (_) => new SocketsHttpHandler()
+            {
+                AutomaticDecompression = DecompressionMethods.All,
+                RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8,
+                ConnectCallback = HttpClientExtension.OnConnect
+            };
+
             Func<IServiceProvider, HttpMessageHandler> defaultHttpClientHandlerDelegate = (_) => new SocketsHttpHandler()
             {
                 AutomaticDecompression = DecompressionMethods.All,
@@ -91,7 +100,7 @@ namespace Jellyfin.Server
                     c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
                     c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
                 })
-                .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate);
+                .ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate);
 
             services.AddHttpClient(NamedClient.MusicBrainz, c =>
                 {
@@ -100,6 +109,15 @@ namespace Jellyfin.Server
                     c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
                     c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
                 })
+                .ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate);
+
+            services.AddHttpClient(NamedClient.DirectIp, c =>
+                {
+                    c.DefaultRequestHeaders.UserAgent.Add(productHeader);
+                    c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader);
+                    c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
+                    c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
+                })
                 .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate);
 
             services.AddHttpClient(NamedClient.Dlna, c =>

+ 7 - 2
MediaBrowser.Common/Net/NamedClient.cs

@@ -1,4 +1,4 @@
-namespace MediaBrowser.Common.Net
+namespace MediaBrowser.Common.Net
 {
     /// <summary>
     /// Registered http client names.
@@ -6,7 +6,7 @@
     public static class NamedClient
     {
         /// <summary>
-        /// Gets the value for the default named http client.
+        /// Gets the value for the default named http client which implements happy eyeballs.
         /// </summary>
         public const string Default = nameof(Default);
 
@@ -19,5 +19,10 @@
         /// Gets the value for the DLNA named http client.
         /// </summary>
         public const string Dlna = nameof(Dlna);
+
+        /// <summary>
+        /// Non happy eyeballs implementation.
+        /// </summary>
+        public const string DirectIp = nameof(DirectIp);
     }
 }

+ 38 - 4
MediaBrowser.Providers/Manager/ItemImageProvider.cs

@@ -32,6 +32,7 @@ namespace MediaBrowser.Providers.Manager
         private readonly ILogger _logger;
         private readonly IProviderManager _providerManager;
         private readonly IFileSystem _fileSystem;
+        private static readonly ImageType[] AllImageTypes = Enum.GetValues<ImageType>();
 
         /// <summary>
         /// Image types that are only one per item.
@@ -90,11 +91,12 @@ namespace MediaBrowser.Providers.Manager
         /// </summary>
         /// <param name="item">The <see cref="BaseItem"/> to validate images for.</param>
         /// <param name="providers">The providers to use, must include <see cref="ILocalImageProvider"/>(s) for local scanning.</param>
-        /// <param name="directoryService">The directory service for <see cref="ILocalImageProvider"/>s to use.</param>
+        /// <param name="refreshOptions">The refresh options.</param>
         /// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns>
-        public bool ValidateImages(BaseItem item, IEnumerable<IImageProvider> providers, IDirectoryService directoryService)
+        public bool ValidateImages(BaseItem item, IEnumerable<IImageProvider> providers, ImageRefreshOptions refreshOptions)
         {
             var hasChanges = false;
+            IDirectoryService directoryService = refreshOptions?.DirectoryService;
 
             if (item is not Photo)
             {
@@ -102,7 +104,7 @@ namespace MediaBrowser.Providers.Manager
                     .SelectMany(i => i.GetImages(item, directoryService))
                     .ToList();
 
-                if (MergeImages(item, images))
+                if (MergeImages(item, images, refreshOptions))
                 {
                     hasChanges = true;
                 }
@@ -381,15 +383,36 @@ namespace MediaBrowser.Providers.Manager
             item.RemoveImages(images);
         }
 
+        /// <summary>
+        /// Merges a list of images into the provided item, validating existing images and replacing them or adding new images as necessary.
+        /// </summary>
+        /// <param name="refreshOptions">The refresh options.</param>
+        /// <param name="dontReplaceImages">List of imageTypes to remove from ReplaceImages.</param>
+        public void UpdateReplaceImages(ImageRefreshOptions refreshOptions, ICollection<ImageType> dontReplaceImages)
+        {
+            if (refreshOptions is not null)
+            {
+                if (refreshOptions.ReplaceAllImages)
+                {
+                    refreshOptions.ReplaceAllImages = false;
+                    refreshOptions.ReplaceImages = AllImageTypes.ToList();
+                }
+
+                refreshOptions.ReplaceImages = refreshOptions.ReplaceImages.Except(dontReplaceImages).ToList();
+            }
+        }
+
         /// <summary>
         /// Merges a list of images into the provided item, validating existing images and replacing them or adding new images as necessary.
         /// </summary>
         /// <param name="item">The <see cref="BaseItem"/> to modify.</param>
         /// <param name="images">The new images to place in <c>item</c>.</param>
+        /// <param name="refreshOptions">The refresh options.</param>
         /// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns>
-        public bool MergeImages(BaseItem item, IReadOnlyList<LocalImageInfo> images)
+        public bool MergeImages(BaseItem item, IReadOnlyList<LocalImageInfo> images, ImageRefreshOptions refreshOptions)
         {
             var changed = item.ValidateImages();
+            var foundImageTypes = new List<ImageType>();
 
             for (var i = 0; i < _singularImages.Length; i++)
             {
@@ -399,6 +422,11 @@ namespace MediaBrowser.Providers.Manager
                 if (image is not null)
                 {
                     var currentImage = item.GetImageInfo(type, 0);
+                    // if image file is stored with media, don't replace that later
+                    if (item.ContainingFolderPath is not null && item.ContainingFolderPath.Contains(Path.GetDirectoryName(image.FileInfo.FullName), StringComparison.OrdinalIgnoreCase))
+                    {
+                        foundImageTypes.Add(type);
+                    }
 
                     if (currentImage is null || !string.Equals(currentImage.Path, image.FileInfo.FullName, StringComparison.OrdinalIgnoreCase))
                     {
@@ -425,6 +453,12 @@ namespace MediaBrowser.Providers.Manager
             if (UpdateMultiImages(item, images, ImageType.Backdrop))
             {
                 changed = true;
+                foundImageTypes.Add(ImageType.Backdrop);
+            }
+
+            if (foundImageTypes.Count > 0)
+            {
+                UpdateReplaceImages(refreshOptions, foundImageTypes);
             }
 
             return changed;

+ 7 - 12
MediaBrowser.Providers/Manager/MetadataService.cs

@@ -26,8 +26,6 @@ namespace MediaBrowser.Providers.Manager
         where TItemType : BaseItem, IHasLookupInfo<TIdType>, new()
         where TIdType : ItemLookupInfo, new()
     {
-        private static readonly ImageType[] AllImageTypes = Enum.GetValues<ImageType>();
-
         protected MetadataService(IServerConfigurationManager serverConfigurationManager, ILogger<MetadataService<TItemType, TIdType>> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager)
         {
             ServerConfigurationManager = serverConfigurationManager;
@@ -110,7 +108,7 @@ namespace MediaBrowser.Providers.Manager
             try
             {
                 // Always validate images and check for new locally stored ones.
-                if (ImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions.DirectoryService))
+                if (ImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions))
                 {
                     updateType |= ItemUpdateType.ImageUpdate;
                 }
@@ -674,8 +672,7 @@ namespace MediaBrowser.Providers.Manager
             }
 
             var hasLocalMetadata = false;
-            var replaceImages = AllImageTypes.ToList();
-            var localImagesFound = false;
+            var foundImageTypes = new List<ImageType>();
 
             foreach (var provider in providers.OfType<ILocalMetadataProvider<TItemType>>())
             {
@@ -703,9 +700,8 @@ namespace MediaBrowser.Providers.Manager
                                 await ProviderManager.SaveImage(item, remoteImage.Url, remoteImage.Type, null, cancellationToken).ConfigureAwait(false);
                                 refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
 
-                                // remove imagetype that has just been downloaded
-                                replaceImages.Remove(remoteImage.Type);
-                                localImagesFound = true;
+                                // remember imagetype that has just been downloaded
+                                foundImageTypes.Add(remoteImage.Type);
                             }
                             catch (HttpRequestException ex)
                             {
@@ -713,13 +709,12 @@ namespace MediaBrowser.Providers.Manager
                             }
                         }
 
-                        if (localImagesFound)
+                        if (foundImageTypes.Count > 0)
                         {
-                            options.ReplaceAllImages = false;
-                            options.ReplaceImages = replaceImages;
+                            imageService.UpdateReplaceImages(options, foundImageTypes);
                         }
 
-                        if (imageService.MergeImages(item, localItem.Images))
+                        if (imageService.MergeImages(item, localItem.Images, options))
                         {
                             refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
                         }

+ 1 - 1
deployment/Dockerfile.fedora.amd64

@@ -1,4 +1,4 @@
-FROM fedora:36
+FROM fedora:39
 # Docker build arguments
 ARG SOURCE_DIR=/jellyfin
 ARG ARTIFACT_DIR=/dist

+ 3 - 3
tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs

@@ -94,7 +94,7 @@ namespace Jellyfin.Providers.Tests.Manager
         public void MergeImages_EmptyItemNewImagesEmpty_NoChange()
         {
             var itemImageProvider = GetItemImageProvider(null, null);
-            var changed = itemImageProvider.MergeImages(new Video(), Array.Empty<LocalImageInfo>());
+            var changed = itemImageProvider.MergeImages(new Video(), Array.Empty<LocalImageInfo>(), new ImageRefreshOptions(Mock.Of<IDirectoryService>()));
 
             Assert.False(changed);
         }
@@ -108,7 +108,7 @@ namespace Jellyfin.Providers.Tests.Manager
             var images = GetImages(imageType, imageCount, false);
 
             var itemImageProvider = GetItemImageProvider(null, null);
-            var changed = itemImageProvider.MergeImages(item, images);
+            var changed = itemImageProvider.MergeImages(item, images, new ImageRefreshOptions(Mock.Of<IDirectoryService>()));
 
             Assert.True(changed);
             // adds for types that allow multiple, replaces singular type images
@@ -151,7 +151,7 @@ namespace Jellyfin.Providers.Tests.Manager
             var images = GetImages(imageType, imageCount, true);
 
             var itemImageProvider = GetItemImageProvider(null, fileSystem);
-            var changed = itemImageProvider.MergeImages(item, images);
+            var changed = itemImageProvider.MergeImages(item, images, new ImageRefreshOptions(Mock.Of<IDirectoryService>()));
 
             if (updateTime)
             {