Explorar o código

Merge branch 'master' into client-logger

Cody Robibero %!s(int64=3) %!d(string=hai) anos
pai
achega
3398f7f953

+ 73 - 81
Emby.Server.Implementations/ApplicationHost.cs

@@ -148,25 +148,20 @@ namespace Emby.Server.Implementations
         /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
         /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
         /// <param name="options">Instance of the <see cref="IStartupOptions"/> interface.</param>
         /// <param name="options">Instance of the <see cref="IStartupOptions"/> interface.</param>
         /// <param name="startupConfig">The <see cref="IConfiguration" /> interface.</param>
         /// <param name="startupConfig">The <see cref="IConfiguration" /> interface.</param>
-        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-        /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param>
         public ApplicationHost(
         public ApplicationHost(
             IServerApplicationPaths applicationPaths,
             IServerApplicationPaths applicationPaths,
             ILoggerFactory loggerFactory,
             ILoggerFactory loggerFactory,
             IStartupOptions options,
             IStartupOptions options,
-            IConfiguration startupConfig,
-            IFileSystem fileSystem,
-            IServiceCollection serviceCollection)
+            IConfiguration startupConfig)
         {
         {
             ApplicationPaths = applicationPaths;
             ApplicationPaths = applicationPaths;
             LoggerFactory = loggerFactory;
             LoggerFactory = loggerFactory;
             _startupOptions = options;
             _startupOptions = options;
             _startupConfig = startupConfig;
             _startupConfig = startupConfig;
-            _fileSystemManager = fileSystem;
-            ServiceCollection = serviceCollection;
+            _fileSystemManager = new ManagedFileSystem(LoggerFactory.CreateLogger<ManagedFileSystem>(), applicationPaths);
 
 
             Logger = LoggerFactory.CreateLogger<ApplicationHost>();
             Logger = LoggerFactory.CreateLogger<ApplicationHost>();
-            fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem));
+            _fileSystemManager.AddShortcutHandler(new MbLinkShortcutHandler(_fileSystemManager));
 
 
             ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version;
             ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version;
             ApplicationVersionString = ApplicationVersion.ToString(3);
             ApplicationVersionString = ApplicationVersion.ToString(3);
@@ -231,8 +226,6 @@ namespace Emby.Server.Implementations
         /// </summary>
         /// </summary>
         protected ILogger<ApplicationHost> Logger { get; }
         protected ILogger<ApplicationHost> Logger { get; }
 
 
-        protected IServiceCollection ServiceCollection { get; }
-
         /// <summary>
         /// <summary>
         /// Gets the logger factory.
         /// Gets the logger factory.
         /// </summary>
         /// </summary>
@@ -522,7 +515,7 @@ namespace Emby.Server.Implementations
         }
         }
 
 
         /// <inheritdoc/>
         /// <inheritdoc/>
-        public void Init()
+        public void Init(IServiceCollection serviceCollection)
         {
         {
             DiscoverTypes();
             DiscoverTypes();
 
 
@@ -552,130 +545,129 @@ namespace Emby.Server.Implementations
             CertificatePath = networkConfiguration.CertificatePath;
             CertificatePath = networkConfiguration.CertificatePath;
             Certificate = GetCertificate(CertificatePath, networkConfiguration.CertificatePassword);
             Certificate = GetCertificate(CertificatePath, networkConfiguration.CertificatePassword);
 
 
-            RegisterServices();
+            RegisterServices(serviceCollection);
 
 
-            _pluginManager.RegisterServices(ServiceCollection);
+            _pluginManager.RegisterServices(serviceCollection);
         }
         }
 
 
         /// <summary>
         /// <summary>
         /// Registers services/resources with the service collection that will be available via DI.
         /// Registers services/resources with the service collection that will be available via DI.
         /// </summary>
         /// </summary>
-        protected virtual void RegisterServices()
+        /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param>
+        protected virtual void RegisterServices(IServiceCollection serviceCollection)
         {
         {
-            ServiceCollection.AddSingleton(_startupOptions);
+            serviceCollection.AddSingleton(_startupOptions);
 
 
-            ServiceCollection.AddMemoryCache();
+            serviceCollection.AddMemoryCache();
 
 
-            ServiceCollection.AddSingleton<IServerConfigurationManager>(ConfigurationManager);
-            ServiceCollection.AddSingleton<IConfigurationManager>(ConfigurationManager);
-            ServiceCollection.AddSingleton<IApplicationHost>(this);
-            ServiceCollection.AddSingleton<IPluginManager>(_pluginManager);
-            ServiceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths);
+            serviceCollection.AddSingleton<IServerConfigurationManager>(ConfigurationManager);
+            serviceCollection.AddSingleton<IConfigurationManager>(ConfigurationManager);
+            serviceCollection.AddSingleton<IApplicationHost>(this);
+            serviceCollection.AddSingleton<IPluginManager>(_pluginManager);
+            serviceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths);
 
 
-            ServiceCollection.AddSingleton(_fileSystemManager);
-            ServiceCollection.AddSingleton<TmdbClientManager>();
+            serviceCollection.AddSingleton(_fileSystemManager);
+            serviceCollection.AddSingleton<TmdbClientManager>();
 
 
-            ServiceCollection.AddSingleton(NetManager);
+            serviceCollection.AddSingleton(NetManager);
 
 
-            ServiceCollection.AddSingleton<ITaskManager, TaskManager>();
+            serviceCollection.AddSingleton<ITaskManager, TaskManager>();
 
 
-            ServiceCollection.AddSingleton(_xmlSerializer);
+            serviceCollection.AddSingleton(_xmlSerializer);
 
 
-            ServiceCollection.AddSingleton<IStreamHelper, StreamHelper>();
+            serviceCollection.AddSingleton<IStreamHelper, StreamHelper>();
 
 
-            ServiceCollection.AddSingleton<ICryptoProvider, CryptographyProvider>();
+            serviceCollection.AddSingleton<ICryptoProvider, CryptographyProvider>();
 
 
-            ServiceCollection.AddSingleton<ISocketFactory, SocketFactory>();
+            serviceCollection.AddSingleton<ISocketFactory, SocketFactory>();
 
 
-            ServiceCollection.AddSingleton<IInstallationManager, InstallationManager>();
+            serviceCollection.AddSingleton<IInstallationManager, InstallationManager>();
 
 
-            ServiceCollection.AddSingleton<IZipClient, ZipClient>();
+            serviceCollection.AddSingleton<IZipClient, ZipClient>();
 
 
-            ServiceCollection.AddSingleton<IServerApplicationHost>(this);
-            ServiceCollection.AddSingleton<IServerApplicationPaths>(ApplicationPaths);
+            serviceCollection.AddSingleton<IServerApplicationHost>(this);
+            serviceCollection.AddSingleton<IServerApplicationPaths>(ApplicationPaths);
 
 
-            ServiceCollection.AddSingleton<ILocalizationManager, LocalizationManager>();
+            serviceCollection.AddSingleton<ILocalizationManager, LocalizationManager>();
 
 
-            ServiceCollection.AddSingleton<IBlurayExaminer, BdInfoExaminer>();
+            serviceCollection.AddSingleton<IBlurayExaminer, BdInfoExaminer>();
 
 
-            ServiceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>();
-            ServiceCollection.AddSingleton<IUserDataManager, UserDataManager>();
+            serviceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>();
+            serviceCollection.AddSingleton<IUserDataManager, UserDataManager>();
 
 
-            ServiceCollection.AddSingleton<IItemRepository, SqliteItemRepository>();
+            serviceCollection.AddSingleton<IItemRepository, SqliteItemRepository>();
 
 
-            ServiceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>();
-            ServiceCollection.AddSingleton<EncodingHelper>();
+            serviceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>();
+            serviceCollection.AddSingleton<EncodingHelper>();
 
 
             // TODO: Refactor to eliminate the circular dependencies here so that Lazy<T> isn't required
             // TODO: Refactor to eliminate the circular dependencies here so that Lazy<T> isn't required
-            ServiceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
-            ServiceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
-            ServiceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
-            ServiceCollection.AddSingleton<ILibraryManager, LibraryManager>();
+            serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
+            serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
+            serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
+            serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
 
 
-            ServiceCollection.AddSingleton<IMusicManager, MusicManager>();
+            serviceCollection.AddSingleton<IMusicManager, MusicManager>();
 
 
-            ServiceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>();
+            serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>();
 
 
-            ServiceCollection.AddSingleton<ISearchEngine, SearchEngine>();
+            serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
 
 
-            ServiceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
+            serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
 
 
-            ServiceCollection.AddSingleton<IImageProcessor, ImageProcessor>();
+            serviceCollection.AddSingleton<IImageProcessor, ImageProcessor>();
 
 
-            ServiceCollection.AddSingleton<ITVSeriesManager, TVSeriesManager>();
+            serviceCollection.AddSingleton<ITVSeriesManager, TVSeriesManager>();
 
 
-            ServiceCollection.AddSingleton<IMediaSourceManager, MediaSourceManager>();
+            serviceCollection.AddSingleton<IMediaSourceManager, MediaSourceManager>();
 
 
-            ServiceCollection.AddSingleton<ISubtitleManager, SubtitleManager>();
+            serviceCollection.AddSingleton<ISubtitleManager, SubtitleManager>();
 
 
-            ServiceCollection.AddSingleton<IProviderManager, ProviderManager>();
+            serviceCollection.AddSingleton<IProviderManager, ProviderManager>();
 
 
             // TODO: Refactor to eliminate the circular dependency here so that Lazy<T> isn't required
             // TODO: Refactor to eliminate the circular dependency here so that Lazy<T> isn't required
-            ServiceCollection.AddTransient(provider => new Lazy<ILiveTvManager>(provider.GetRequiredService<ILiveTvManager>));
-            ServiceCollection.AddSingleton<IDtoService, DtoService>();
-
-            ServiceCollection.AddSingleton<IChannelManager, ChannelManager>();
-
-            ServiceCollection.AddSingleton<ISessionManager, SessionManager>();
+            serviceCollection.AddTransient(provider => new Lazy<ILiveTvManager>(provider.GetRequiredService<ILiveTvManager>));
+            serviceCollection.AddSingleton<IDtoService, DtoService>();
 
 
-            ServiceCollection.AddSingleton<IDlnaManager, DlnaManager>();
+            serviceCollection.AddSingleton<IChannelManager, ChannelManager>();
 
 
-            ServiceCollection.AddSingleton<ICollectionManager, CollectionManager>();
+            serviceCollection.AddSingleton<ISessionManager, SessionManager>();
 
 
-            ServiceCollection.AddSingleton<IPlaylistManager, PlaylistManager>();
+            serviceCollection.AddSingleton<IDlnaManager, DlnaManager>();
 
 
-            ServiceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>();
+            serviceCollection.AddSingleton<ICollectionManager, CollectionManager>();
 
 
-            ServiceCollection.AddSingleton<LiveTvDtoService>();
-            ServiceCollection.AddSingleton<ILiveTvManager, LiveTvManager>();
+            serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>();
 
 
-            ServiceCollection.AddSingleton<IUserViewManager, UserViewManager>();
+            serviceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>();
 
 
-            ServiceCollection.AddSingleton<INotificationManager, NotificationManager>();
+            serviceCollection.AddSingleton<LiveTvDtoService>();
+            serviceCollection.AddSingleton<ILiveTvManager, LiveTvManager>();
 
 
-            ServiceCollection.AddSingleton<IDeviceDiscovery, DeviceDiscovery>();
+            serviceCollection.AddSingleton<IUserViewManager, UserViewManager>();
 
 
-            ServiceCollection.AddSingleton<IChapterManager, ChapterManager>();
+            serviceCollection.AddSingleton<INotificationManager, NotificationManager>();
 
 
-            ServiceCollection.AddSingleton<IEncodingManager, MediaEncoder.EncodingManager>();
+            serviceCollection.AddSingleton<IDeviceDiscovery, DeviceDiscovery>();
 
 
-            ServiceCollection.AddScoped<ISessionContext, SessionContext>();
+            serviceCollection.AddSingleton<IChapterManager, ChapterManager>();
 
 
-            ServiceCollection.AddSingleton<IAuthService, AuthService>();
-            ServiceCollection.AddSingleton<IQuickConnect, QuickConnectManager>();
+            serviceCollection.AddSingleton<IEncodingManager, MediaEncoder.EncodingManager>();
 
 
-            ServiceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>();
+            serviceCollection.AddScoped<ISessionContext, SessionContext>();
 
 
-            ServiceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>();
+            serviceCollection.AddSingleton<IAuthService, AuthService>();
+            serviceCollection.AddSingleton<IQuickConnect, QuickConnectManager>();
 
 
-            ServiceCollection.AddSingleton<TranscodingJobHelper>();
-            ServiceCollection.AddScoped<MediaInfoHelper>();
-            ServiceCollection.AddScoped<AudioHelper>();
-            ServiceCollection.AddScoped<DynamicHlsHelper>();
+            serviceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>();
 
 
-            ServiceCollection.AddScoped<IClientEventLogger, ClientEventLogger>();
+            serviceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>();
 
 
-            ServiceCollection.AddSingleton<IDirectoryService, DirectoryService>();
+            serviceCollection.AddSingleton<TranscodingJobHelper>();
+            serviceCollection.AddScoped<MediaInfoHelper>();
+            serviceCollection.AddScoped<AudioHelper>();
+            serviceCollection.AddScoped<DynamicHlsHelper>();
+            serviceCollection.AddScoped<IClientEventLogger, ClientEventLogger>();
+            serviceCollection.AddSingleton<IDirectoryService, DirectoryService>();
         }
         }
 
 
         /// <summary>
         /// <summary>

+ 1 - 1
Emby.Server.Implementations/Localization/countries.json

@@ -630,7 +630,7 @@
         "TwoLetterISORegionName": "MD"
         "TwoLetterISORegionName": "MD"
     },
     },
     {
     {
-        "DisplayName": "Réunion",
+        "DisplayName": "Réunion",
         "Name": "RE",
         "Name": "RE",
         "ThreeLetterISORegionName": "REU",
         "ThreeLetterISORegionName": "REU",
         "TwoLetterISORegionName": "RE"
         "TwoLetterISORegionName": "RE"

+ 2 - 1
Emby.Server.Implementations/Localization/iso6392.txt

@@ -349,7 +349,8 @@ pli||pi|Pali|pali
 pol||pl|Polish|polonais
 pol||pl|Polish|polonais
 pon|||Pohnpeian|pohnpei
 pon|||Pohnpeian|pohnpei
 por||pt|Portuguese|portugais
 por||pt|Portuguese|portugais
-pob||pt-br|Portuguese (Brazil)|portugais
+pop||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
+pob||pt-br|Portuguese (Brazil)|portugais (pt-br)
 pra|||Prakrit languages|prâkrit, langues
 pra|||Prakrit languages|prâkrit, langues
 pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500)
 pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500)
 pus||ps|Pushto; Pashto|pachto
 pus||ps|Pushto; Pashto|pachto

+ 21 - 28
Jellyfin.Server/CoreAppHost.cs

@@ -22,7 +22,6 @@ using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Security;
 using MediaBrowser.Controller.Security;
 using MediaBrowser.Model.Activity;
 using MediaBrowser.Model.Activity;
-using MediaBrowser.Model.IO;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
@@ -42,67 +41,61 @@ namespace Jellyfin.Server
         /// <param name="loggerFactory">The <see cref="ILoggerFactory" /> to be used by the <see cref="CoreAppHost" />.</param>
         /// <param name="loggerFactory">The <see cref="ILoggerFactory" /> to be used by the <see cref="CoreAppHost" />.</param>
         /// <param name="options">The <see cref="StartupOptions" /> to be used by the <see cref="CoreAppHost" />.</param>
         /// <param name="options">The <see cref="StartupOptions" /> to be used by the <see cref="CoreAppHost" />.</param>
         /// <param name="startupConfig">The <see cref="IConfiguration" /> to be used by the <see cref="CoreAppHost" />.</param>
         /// <param name="startupConfig">The <see cref="IConfiguration" /> to be used by the <see cref="CoreAppHost" />.</param>
-        /// <param name="fileSystem">The <see cref="IFileSystem" /> to be used by the <see cref="CoreAppHost" />.</param>
-        /// <param name="collection">The <see cref="IServiceCollection"/> to be used by the <see cref="CoreAppHost"/>.</param>
         public CoreAppHost(
         public CoreAppHost(
             IServerApplicationPaths applicationPaths,
             IServerApplicationPaths applicationPaths,
             ILoggerFactory loggerFactory,
             ILoggerFactory loggerFactory,
             IStartupOptions options,
             IStartupOptions options,
-            IConfiguration startupConfig,
-            IFileSystem fileSystem,
-            IServiceCollection collection)
+            IConfiguration startupConfig)
             : base(
             : base(
                 applicationPaths,
                 applicationPaths,
                 loggerFactory,
                 loggerFactory,
                 options,
                 options,
-                startupConfig,
-                fileSystem,
-                collection)
+                startupConfig)
         {
         {
         }
         }
 
 
         /// <inheritdoc/>
         /// <inheritdoc/>
-        protected override void RegisterServices()
+        protected override void RegisterServices(IServiceCollection serviceCollection)
         {
         {
             // Register an image encoder
             // Register an image encoder
             bool useSkiaEncoder = SkiaEncoder.IsNativeLibAvailable();
             bool useSkiaEncoder = SkiaEncoder.IsNativeLibAvailable();
             Type imageEncoderType = useSkiaEncoder
             Type imageEncoderType = useSkiaEncoder
                 ? typeof(SkiaEncoder)
                 ? typeof(SkiaEncoder)
                 : typeof(NullImageEncoder);
                 : typeof(NullImageEncoder);
-            ServiceCollection.AddSingleton(typeof(IImageEncoder), imageEncoderType);
+            serviceCollection.AddSingleton(typeof(IImageEncoder), imageEncoderType);
 
 
             // Log a warning if the Skia encoder could not be used
             // Log a warning if the Skia encoder could not be used
             if (!useSkiaEncoder)
             if (!useSkiaEncoder)
             {
             {
-                Logger.LogWarning($"Skia not available. Will fallback to {nameof(NullImageEncoder)}.");
+                Logger.LogWarning("Skia not available. Will fallback to {ImageEncoder}.", nameof(NullImageEncoder));
             }
             }
 
 
-            ServiceCollection.AddDbContextPool<JellyfinDb>(
+            serviceCollection.AddDbContextPool<JellyfinDb>(
                  options => options
                  options => options
                     .UseLoggerFactory(LoggerFactory)
                     .UseLoggerFactory(LoggerFactory)
                     .UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"));
                     .UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"));
 
 
-            ServiceCollection.AddEventServices();
-            ServiceCollection.AddSingleton<IBaseItemManager, BaseItemManager>();
-            ServiceCollection.AddSingleton<IEventManager, EventManager>();
-            ServiceCollection.AddSingleton<JellyfinDbProvider>();
+            serviceCollection.AddEventServices();
+            serviceCollection.AddSingleton<IBaseItemManager, BaseItemManager>();
+            serviceCollection.AddSingleton<IEventManager, EventManager>();
+            serviceCollection.AddSingleton<JellyfinDbProvider>();
 
 
-            ServiceCollection.AddSingleton<IActivityManager, ActivityManager>();
-            ServiceCollection.AddSingleton<IUserManager, UserManager>();
-            ServiceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>();
-            ServiceCollection.AddSingleton<IDeviceManager, DeviceManager>();
+            serviceCollection.AddSingleton<IActivityManager, ActivityManager>();
+            serviceCollection.AddSingleton<IUserManager, UserManager>();
+            serviceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>();
+            serviceCollection.AddSingleton<IDeviceManager, DeviceManager>();
 
 
             // TODO search the assemblies instead of adding them manually?
             // TODO search the assemblies instead of adding them manually?
-            ServiceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>();
-            ServiceCollection.AddSingleton<IWebSocketListener, ActivityLogWebSocketListener>();
-            ServiceCollection.AddSingleton<IWebSocketListener, ScheduledTasksWebSocketListener>();
-            ServiceCollection.AddSingleton<IWebSocketListener, SessionInfoWebSocketListener>();
+            serviceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>();
+            serviceCollection.AddSingleton<IWebSocketListener, ActivityLogWebSocketListener>();
+            serviceCollection.AddSingleton<IWebSocketListener, ScheduledTasksWebSocketListener>();
+            serviceCollection.AddSingleton<IWebSocketListener, SessionInfoWebSocketListener>();
 
 
-            ServiceCollection.AddSingleton<IAuthorizationContext, AuthorizationContext>();
+            serviceCollection.AddSingleton<IAuthorizationContext, AuthorizationContext>();
 
 
-            ServiceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>();
+            serviceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>();
 
 
-            base.RegisterServices();
+            base.RegisterServices(serviceCollection);
         }
         }
 
 
         /// <inheritdoc />
         /// <inheritdoc />

+ 21 - 20
Jellyfin.Server/Program.cs

@@ -10,7 +10,6 @@ using System.Threading;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using CommandLine;
 using CommandLine;
 using Emby.Server.Implementations;
 using Emby.Server.Implementations;
-using Emby.Server.Implementations.IO;
 using Jellyfin.Server.Implementations;
 using Jellyfin.Server.Implementations;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Common.Net;
@@ -159,34 +158,36 @@ namespace Jellyfin.Server
 
 
             ApplicationHost.LogEnvironmentInfo(_logger, appPaths);
             ApplicationHost.LogEnvironmentInfo(_logger, appPaths);
 
 
+            // If hosting the web client, validate the client content path
+            if (startupConfig.HostWebClient())
+            {
+                string? webContentPath = appPaths.WebPath;
+                if (!Directory.Exists(webContentPath) || !Directory.EnumerateFiles(webContentPath).Any())
+                {
+                    _logger.LogError(
+                        "The server is expected to host the web client, but the provided content directory is either " +
+                        "invalid or empty: {WebContentPath}. If you do not want to host the web client with the " +
+                        "server, you may set the '--nowebclient' command line flag, or set" +
+                        "'{ConfigKey}=false' in your config settings.",
+                        webContentPath,
+                        ConfigurationExtensions.HostWebClientKey);
+                    Environment.ExitCode = 1;
+                    return;
+                }
+            }
+
             PerformStaticInitialization();
             PerformStaticInitialization();
-            var serviceCollection = new ServiceCollection();
 
 
             var appHost = new CoreAppHost(
             var appHost = new CoreAppHost(
                 appPaths,
                 appPaths,
                 _loggerFactory,
                 _loggerFactory,
                 options,
                 options,
-                startupConfig,
-                new ManagedFileSystem(_loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths),
-                serviceCollection);
+                startupConfig);
 
 
             try
             try
             {
             {
-                // If hosting the web client, validate the client content path
-                if (startupConfig.HostWebClient())
-                {
-                    string? webContentPath = appHost.ConfigurationManager.ApplicationPaths.WebPath;
-                    if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0)
-                    {
-                        throw new InvalidOperationException(
-                            "The server is expected to host the web client, but the provided content directory is either " +
-                            $"invalid or empty: {webContentPath}. If you do not want to host the web client with the " +
-                            "server, you may set the '--nowebclient' command line flag, or set" +
-                            $"'{ConfigurationExtensions.HostWebClientKey}=false' in your config settings.");
-                    }
-                }
-
-                appHost.Init();
+                var serviceCollection = new ServiceCollection();
+                appHost.Init(serviceCollection);
 
 
                 var webHost = new WebHostBuilder().ConfigureWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build();
                 var webHost = new WebHostBuilder().ConfigureWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build();
 
 

+ 3 - 1
MediaBrowser.Common/IApplicationHost.cs

@@ -2,6 +2,7 @@ using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Reflection;
 using System.Reflection;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
 
 
 namespace MediaBrowser.Common
 namespace MediaBrowser.Common
 {
 {
@@ -137,7 +138,8 @@ namespace MediaBrowser.Common
         /// <summary>
         /// <summary>
         /// Initializes this instance.
         /// Initializes this instance.
         /// </summary>
         /// </summary>
-        void Init();
+        /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param>
+        void Init(IServiceCollection serviceCollection);
 
 
         /// <summary>
         /// <summary>
         /// Creates the instance.
         /// Creates the instance.

+ 3 - 18
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -2345,7 +2345,7 @@ namespace MediaBrowser.Controller.Entities
             RemoveImages(new List<ItemImageInfo> { image });
             RemoveImages(new List<ItemImageInfo> { image });
         }
         }
 
 
-        public void RemoveImages(List<ItemImageInfo> deletedImages)
+        public void RemoveImages(IEnumerable<ItemImageInfo> deletedImages)
         {
         {
             ImageInfos = ImageInfos.Except(deletedImages).ToArray();
             ImageInfos = ImageInfos.Except(deletedImages).ToArray();
         }
         }
@@ -2495,11 +2495,11 @@ namespace MediaBrowser.Controller.Entities
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Adds the images.
+        /// Adds the images, updating metadata if they already are part of this item.
         /// </summary>
         /// </summary>
         /// <param name="imageType">Type of the image.</param>
         /// <param name="imageType">Type of the image.</param>
         /// <param name="images">The images.</param>
         /// <param name="images">The images.</param>
-        /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns>
+        /// <returns><c>true</c> if images were added or updated, <c>false</c> otherwise.</returns>
         /// <exception cref="ArgumentException">Cannot call AddImages with chapter images.</exception>
         /// <exception cref="ArgumentException">Cannot call AddImages with chapter images.</exception>
         public bool AddImages(ImageType imageType, List<FileSystemMetadata> images)
         public bool AddImages(ImageType imageType, List<FileSystemMetadata> images)
         {
         {
@@ -2512,7 +2512,6 @@ namespace MediaBrowser.Controller.Entities
                 .ToList();
                 .ToList();
 
 
             var newImageList = new List<FileSystemMetadata>();
             var newImageList = new List<FileSystemMetadata>();
-            var imageAdded = false;
             var imageUpdated = false;
             var imageUpdated = false;
 
 
             foreach (var newImage in images)
             foreach (var newImage in images)
@@ -2528,7 +2527,6 @@ namespace MediaBrowser.Controller.Entities
                 if (existing == null)
                 if (existing == null)
                 {
                 {
                     newImageList.Add(newImage);
                     newImageList.Add(newImage);
-                    imageAdded = true;
                 }
                 }
                 else
                 else
                 {
                 {
@@ -2549,19 +2547,6 @@ namespace MediaBrowser.Controller.Entities
                 }
                 }
             }
             }
 
 
-            if (imageAdded || images.Count != existingImages.Count)
-            {
-                var newImagePaths = images.Select(i => i.FullName).ToList();
-
-                var deleted = existingImages
-                    .FindAll(i => i.IsLocalFile && !newImagePaths.Contains(i.Path.AsSpan(), StringComparison.OrdinalIgnoreCase) && !File.Exists(i.Path));
-
-                if (deleted.Count > 0)
-                {
-                    ImageInfos = ImageInfos.Except(deleted).ToArray();
-                }
-            }
-
             if (newImageList.Count > 0)
             if (newImageList.Count > 0)
             {
             {
                 ImageInfos = ImageInfos.Concat(newImageList.Select(i => GetImageInfo(i, imageType))).ToArray();
                 ImageInfos = ImageInfos.Concat(newImageList.Select(i => GetImageInfo(i, imageType))).ToArray();

+ 6 - 6
MediaBrowser.Model/Dlna/StreamBuilder.cs

@@ -455,7 +455,7 @@ namespace MediaBrowser.Model.Dlna
 
 
             if (directPlayProfile == null)
             if (directPlayProfile == null)
             {
             {
-                _logger.LogInformation(
+                _logger.LogDebug(
                     "Profile: {0}, No audio direct play profiles found for {1} with codec {2}",
                     "Profile: {0}, No audio direct play profiles found for {1} with codec {2}",
                     options.Profile.Name ?? "Unknown Profile",
                     options.Profile.Name ?? "Unknown Profile",
                     item.Path ?? "Unknown path",
                     item.Path ?? "Unknown path",
@@ -682,7 +682,7 @@ namespace MediaBrowser.Model.Dlna
             bool isEligibleForDirectPlay = options.EnableDirectPlay && (options.ForceDirectPlay || directPlayEligibilityResult.Item1);
             bool isEligibleForDirectPlay = options.EnableDirectPlay && (options.ForceDirectPlay || directPlayEligibilityResult.Item1);
             bool isEligibleForDirectStream = options.EnableDirectStream && (options.ForceDirectStream || directStreamEligibilityResult.Item1);
             bool isEligibleForDirectStream = options.EnableDirectStream && (options.ForceDirectStream || directStreamEligibilityResult.Item1);
 
 
-            _logger.LogInformation(
+            _logger.LogDebug(
                 "Profile: {0}, Path: {1}, isEligibleForDirectPlay: {2}, isEligibleForDirectStream: {3}",
                 "Profile: {0}, Path: {1}, isEligibleForDirectPlay: {2}, isEligibleForDirectStream: {3}",
                 options.Profile.Name ?? "Unknown Profile",
                 options.Profile.Name ?? "Unknown Profile",
                 item.Path ?? "Unknown path",
                 item.Path ?? "Unknown path",
@@ -1033,7 +1033,7 @@ namespace MediaBrowser.Model.Dlna
 
 
             if (directPlay == null)
             if (directPlay == null)
             {
             {
-                _logger.LogInformation(
+                _logger.LogDebug(
                     "Container: {Container}, Video: {Video}, Audio: {Audio} cannot be direct played by profile: {Profile} for path: {Path}",
                     "Container: {Container}, Video: {Video}, Audio: {Audio} cannot be direct played by profile: {Profile} for path: {Path}",
                     container,
                     container,
                     videoStream?.Codec ?? "no video",
                     videoStream?.Codec ?? "no video",
@@ -1198,7 +1198,7 @@ namespace MediaBrowser.Model.Dlna
 
 
         private void LogConditionFailure(DeviceProfile profile, string type, ProfileCondition condition, MediaSourceInfo mediaSource)
         private void LogConditionFailure(DeviceProfile profile, string type, ProfileCondition condition, MediaSourceInfo mediaSource)
         {
         {
-            _logger.LogInformation(
+            _logger.LogDebug(
                 "Profile: {0}, DirectPlay=false. Reason={1}.{2} Condition: {3}. ConditionValue: {4}. IsRequired: {5}. Path: {6}",
                 "Profile: {0}, DirectPlay=false. Reason={1}.{2} Condition: {3}. ConditionValue: {4}. IsRequired: {5}. Path: {6}",
                 type,
                 type,
                 profile.Name ?? "Unknown Profile",
                 profile.Name ?? "Unknown Profile",
@@ -1222,7 +1222,7 @@ namespace MediaBrowser.Model.Dlna
 
 
                 if (subtitleProfile.Method != SubtitleDeliveryMethod.External && subtitleProfile.Method != SubtitleDeliveryMethod.Embed)
                 if (subtitleProfile.Method != SubtitleDeliveryMethod.External && subtitleProfile.Method != SubtitleDeliveryMethod.Embed)
                 {
                 {
-                    _logger.LogInformation("Not eligible for {0} due to unsupported subtitles", playMethod);
+                    _logger.LogDebug("Not eligible for {0} due to unsupported subtitles", playMethod);
                     return (false, TranscodeReason.SubtitleCodecNotSupported);
                     return (false, TranscodeReason.SubtitleCodecNotSupported);
                 }
                 }
             }
             }
@@ -1404,7 +1404,7 @@ namespace MediaBrowser.Model.Dlna
 
 
             if (itemBitrate > requestedMaxBitrate)
             if (itemBitrate > requestedMaxBitrate)
             {
             {
-                _logger.LogInformation(
+                _logger.LogDebug(
                     "Bitrate exceeds {PlayBackMethod} limit: media bitrate: {MediaBitrate}, max bitrate: {MaxBitrate}",
                     "Bitrate exceeds {PlayBackMethod} limit: media bitrate: {MediaBitrate}, max bitrate: {MaxBitrate}",
                     playMethod,
                     playMethod,
                     itemBitrate,
                     itemBitrate,

+ 75 - 58
MediaBrowser.Providers/Manager/ItemImageProvider.cs

@@ -1,7 +1,5 @@
 #nullable disable
 #nullable disable
 
 
-#pragma warning disable CA1002, CS1591
-
 using System;
 using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.IO;
 using System.IO;
@@ -25,6 +23,9 @@ using Microsoft.Extensions.Logging;
 
 
 namespace MediaBrowser.Providers.Manager
 namespace MediaBrowser.Providers.Manager
 {
 {
+    /// <summary>
+    /// Utilities for managing images attached to items.
+    /// </summary>
     public class ItemImageProvider
     public class ItemImageProvider
     {
     {
         private readonly ILogger _logger;
         private readonly ILogger _logger;
@@ -47,6 +48,12 @@ namespace MediaBrowser.Providers.Manager
             ImageType.Thumb
             ImageType.Thumb
         };
         };
 
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ItemImageProvider"/> class.
+        /// </summary>
+        /// <param name="logger">The logger.</param>
+        /// <param name="providerManager">The provider manager for interacting with provider image references.</param>
+        /// <param name="fileSystem">The filesystem.</param>
         public ItemImageProvider(ILogger logger, IProviderManager providerManager, IFileSystem fileSystem)
         public ItemImageProvider(ILogger logger, IProviderManager providerManager, IFileSystem fileSystem)
         {
         {
             _logger = logger;
             _logger = logger;
@@ -54,6 +61,13 @@ namespace MediaBrowser.Providers.Manager
             _fileSystem = fileSystem;
             _fileSystem = fileSystem;
         }
         }
 
 
+        /// <summary>
+        /// Verifies existing images have valid paths and adds any new local images provided.
+        /// </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>
+        /// <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, IDirectoryService directoryService)
         {
         {
             var hasChanges = false;
             var hasChanges = false;
@@ -73,6 +87,15 @@ namespace MediaBrowser.Providers.Manager
             return hasChanges;
             return hasChanges;
         }
         }
 
 
+        /// <summary>
+        /// Refreshes from the providers according to the given options.
+        /// </summary>
+        /// <param name="item">The <see cref="BaseItem"/> to gather images for.</param>
+        /// <param name="libraryOptions">The library options.</param>
+        /// <param name="providers">The providers to query for images.</param>
+        /// <param name="refreshOptions">The refresh options.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>The refresh result.</returns>
         public async Task<RefreshResult> RefreshImages(
         public async Task<RefreshResult> RefreshImages(
             BaseItem item,
             BaseItem item,
             LibraryOptions libraryOptions,
             LibraryOptions libraryOptions,
@@ -80,14 +103,16 @@ namespace MediaBrowser.Providers.Manager
             ImageRefreshOptions refreshOptions,
             ImageRefreshOptions refreshOptions,
             CancellationToken cancellationToken)
             CancellationToken cancellationToken)
         {
         {
+            var oldBackdropImages = Array.Empty<ItemImageInfo>();
             if (refreshOptions.IsReplacingImage(ImageType.Backdrop))
             if (refreshOptions.IsReplacingImage(ImageType.Backdrop))
             {
             {
-                ClearImages(item, ImageType.Backdrop);
+                oldBackdropImages = item.GetImages(ImageType.Backdrop).ToArray();
             }
             }
 
 
+            var oldScreenshotImages = Array.Empty<ItemImageInfo>();
             if (refreshOptions.IsReplacingImage(ImageType.Screenshot))
             if (refreshOptions.IsReplacingImage(ImageType.Screenshot))
             {
             {
-                ClearImages(item, ImageType.Screenshot);
+                oldScreenshotImages = item.GetImages(ImageType.Screenshot).ToArray();
             }
             }
 
 
             var result = new RefreshResult { UpdateType = ItemUpdateType.None };
             var result = new RefreshResult { UpdateType = ItemUpdateType.None };
@@ -95,9 +120,9 @@ namespace MediaBrowser.Providers.Manager
             var typeName = item.GetType().Name;
             var typeName = item.GetType().Name;
             var typeOptions = libraryOptions.GetTypeOptions(typeName) ?? new TypeOptions { Type = typeName };
             var typeOptions = libraryOptions.GetTypeOptions(typeName) ?? new TypeOptions { Type = typeName };
 
 
-            // In order to avoid duplicates, only download these if there are none already
-            var backdropLimit = typeOptions.GetLimit(ImageType.Backdrop);
-            var screenshotLimit = typeOptions.GetLimit(ImageType.Screenshot);
+            // track library limits, adding buffer to allow lazy replacing of current images
+            var backdropLimit = typeOptions.GetLimit(ImageType.Backdrop) + oldBackdropImages.Length;
+            var screenshotLimit = typeOptions.GetLimit(ImageType.Screenshot) + oldScreenshotImages.Length;
             var downloadedImages = new List<ImageType>();
             var downloadedImages = new List<ImageType>();
 
 
             foreach (var provider in providers)
             foreach (var provider in providers)
@@ -114,11 +139,22 @@ namespace MediaBrowser.Providers.Manager
                 }
                 }
             }
             }
 
 
+            // only delete existing multi-images if new ones were added
+            if (oldBackdropImages.Length > 0 && oldBackdropImages.Length < item.GetImages(ImageType.Backdrop).Count())
+            {
+                PruneImages(item, oldBackdropImages);
+            }
+
+            if (oldScreenshotImages.Length > 0 && oldScreenshotImages.Length < item.GetImages(ImageType.Screenshot).Count())
+            {
+                PruneImages(item, oldScreenshotImages);
+            }
+
             return result;
             return result;
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Refreshes from provider.
+        /// Refreshes from a dynamic provider.
         /// </summary>
         /// </summary>
         private async Task RefreshFromProvider(
         private async Task RefreshFromProvider(
             BaseItem item,
             BaseItem item,
@@ -153,13 +189,14 @@ namespace MediaBrowser.Providers.Manager
                                 if (response.Protocol == MediaProtocol.Http)
                                 if (response.Protocol == MediaProtocol.Http)
                                 {
                                 {
                                     _logger.LogDebug("Setting image url into item {0}", item.Id);
                                     _logger.LogDebug("Setting image url into item {0}", item.Id);
+                                    var index = item.AllowsMultipleImages(imageType) ? item.GetImages(imageType).Count() : 0;
                                     item.SetImage(
                                     item.SetImage(
                                         new ItemImageInfo
                                         new ItemImageInfo
                                         {
                                         {
                                             Path = response.Path,
                                             Path = response.Path,
                                             Type = imageType
                                             Type = imageType
                                         },
                                         },
-                                        0);
+                                        index);
                                 }
                                 }
                                 else
                                 else
                                 {
                                 {
@@ -234,7 +271,7 @@ namespace MediaBrowser.Providers.Manager
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// Refreshes from provider.
+        /// Refreshes from a remote provider.
         /// </summary>
         /// </summary>
         /// <param name="item">The item.</param>
         /// <param name="item">The item.</param>
         /// <param name="provider">The provider.</param>
         /// <param name="provider">The provider.</param>
@@ -305,12 +342,12 @@ namespace MediaBrowser.Providers.Manager
                 }
                 }
 
 
                 minWidth = savedOptions.GetMinWidth(ImageType.Backdrop);
                 minWidth = savedOptions.GetMinWidth(ImageType.Backdrop);
-                await DownloadBackdrops(item, ImageType.Backdrop, backdropLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false);
+                await DownloadMultiImages(item, ImageType.Backdrop, refreshOptions, backdropLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false);
 
 
-                if (item is IHasScreenshots hasScreenshots)
+                if (item is IHasScreenshots)
                 {
                 {
                     minWidth = savedOptions.GetMinWidth(ImageType.Screenshot);
                     minWidth = savedOptions.GetMinWidth(ImageType.Screenshot);
-                    await DownloadBackdrops(item, ImageType.Screenshot, screenshotLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false);
+                    await DownloadMultiImages(item, ImageType.Screenshot, refreshOptions, screenshotLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false);
                 }
                 }
             }
             }
             catch (OperationCanceledException)
             catch (OperationCanceledException)
@@ -329,40 +366,36 @@ namespace MediaBrowser.Providers.Manager
             return options.IsEnabled(type);
             return options.IsEnabled(type);
         }
         }
 
 
-        private void ClearImages(BaseItem item, ImageType type)
+        private void PruneImages(BaseItem item, ItemImageInfo[] images)
         {
         {
-            var deleted = false;
-            var deletedImages = new List<ItemImageInfo>();
-
-            foreach (var image in item.GetImages(type))
+            for (var i = 0; i < images.Length; i++)
             {
             {
-                if (!image.IsLocalFile)
-                {
-                    deletedImages.Add(image);
-                    continue;
-                }
+                var image = images[i];
 
 
-                try
-                {
-                    _fileSystem.DeleteFile(image.Path);
-                    deleted = true;
-                }
-                catch (FileNotFoundException)
+                if (image.IsLocalFile)
                 {
                 {
+                    try
+                    {
+                        _fileSystem.DeleteFile(image.Path);
+                    }
+                    catch (FileNotFoundException)
+                    {
+                    }
                 }
                 }
             }
             }
 
 
-            item.RemoveImages(deletedImages);
-
-            if (deleted)
-            {
-                item.ValidateImages(new DirectoryService(_fileSystem));
-            }
+            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="item">The <see cref="BaseItem"/> to modify.</param>
+        /// <param name="images">The new images to place in <c>item</c>.</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)
         {
         {
-            var changed = false;
+            var changed = item.ValidateImages(new DirectoryService(_fileSystem));
 
 
             for (var i = 0; i < _singularImages.Length; i++)
             for (var i = 0; i < _singularImages.Length; i++)
             {
             {
@@ -398,18 +431,6 @@ namespace MediaBrowser.Providers.Manager
                         currentImage.DateModified = newDateModified;
                         currentImage.DateModified = newDateModified;
                     }
                     }
                 }
                 }
-                else
-                {
-                    var existing = item.GetImageInfo(type, 0);
-                    if (existing != null)
-                    {
-                        if (existing.IsLocalFile && !File.Exists(existing.Path))
-                        {
-                            item.RemoveImage(existing);
-                            changed = true;
-                        }
-                    }
-                }
             }
             }
 
 
             if (UpdateMultiImages(item, images, ImageType.Backdrop))
             if (UpdateMultiImages(item, images, ImageType.Backdrop))
@@ -417,13 +438,9 @@ namespace MediaBrowser.Providers.Manager
                 changed = true;
                 changed = true;
             }
             }
 
 
-            var hasScreenshots = item as IHasScreenshots;
-            if (hasScreenshots != null)
+            if (item is IHasScreenshots && UpdateMultiImages(item, images, ImageType.Screenshot))
             {
             {
-                if (UpdateMultiImages(item, images, ImageType.Screenshot))
-                {
-                    changed = true;
-                }
+                changed = true;
             }
             }
 
 
             return changed;
             return changed;
@@ -536,7 +553,7 @@ namespace MediaBrowser.Providers.Manager
                 return true;
                 return true;
             }
             }
 
 
-            if (item is IItemByName && item is not MusicArtist)
+            if (item is IItemByName and not MusicArtist)
             {
             {
                 var hasDualAccess = item as IHasDualAccess;
                 var hasDualAccess = item as IHasDualAccess;
                 if (hasDualAccess == null || hasDualAccess.IsAccessedByName)
                 if (hasDualAccess == null || hasDualAccess.IsAccessedByName)
@@ -569,7 +586,7 @@ namespace MediaBrowser.Providers.Manager
                 newIndex);
                 newIndex);
         }
         }
 
 
-        private async Task DownloadBackdrops(BaseItem item, ImageType imageType, int limit, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, int minWidth, CancellationToken cancellationToken)
+        private async Task DownloadMultiImages(BaseItem item, ImageType imageType, ImageRefreshOptions refreshOptions, int limit, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, int minWidth, CancellationToken cancellationToken)
         {
         {
             foreach (var image in images.Where(i => i.Type == imageType))
             foreach (var image in images.Where(i => i.Type == imageType))
             {
             {
@@ -609,8 +626,8 @@ namespace MediaBrowser.Providers.Manager
                         break;
                         break;
                     }
                     }
 
 
-                    // If there's already an image of the same size, skip it
-                    if (response.Content.Headers.ContentLength.HasValue)
+                    // If there's already an image of the same file size, skip it unless doing a full refresh
+                    if (response.Content.Headers.ContentLength.HasValue && !refreshOptions.IsReplacingImage(imageType))
                     {
                     {
                         try
                         try
                         {
                         {

+ 6 - 0
tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj

@@ -6,6 +6,12 @@
     <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet>
     <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet>
   </PropertyGroup>
   </PropertyGroup>
 
 
+  <ItemGroup>
+    <None Include="Test Data\**\*.*">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+  </ItemGroup>
+
   <ItemGroup>
   <ItemGroup>
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
     <PackageReference Include="Moq" Version="4.16.1" />
     <PackageReference Include="Moq" Version="4.16.1" />

+ 606 - 0
tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs

@@ -0,0 +1,606 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Drawing;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Providers;
+using MediaBrowser.Providers.Manager;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Manager
+{
+    public class ItemImageProviderTests
+    {
+        private static readonly string TestDataImagePath = "Test Data/Images/blank{0}.jpg";
+
+        [Fact]
+        public void ValidateImages_PhotoEmptyProviders_NoChange()
+        {
+            var itemImageProvider = GetItemImageProvider(null, null);
+            var changed = itemImageProvider.ValidateImages(new Photo(), new List<ILocalImageProvider>(), null);
+
+            Assert.False(changed);
+        }
+
+        [Fact]
+        public void ValidateImages_EmptyItemEmptyProviders_NoChange()
+        {
+            var itemImageProvider = GetItemImageProvider(null, null);
+            var changed = itemImageProvider.ValidateImages(new MovieWithScreenshots(), new List<ILocalImageProvider>(), null);
+
+            Assert.False(changed);
+        }
+
+        private static TheoryData<ImageType, int> GetImageTypesWithCount()
+        {
+            var theoryTypes = new TheoryData<ImageType, int>
+            {
+                // minimal test cases that hit different handling
+                { ImageType.Primary, 1 },
+                { ImageType.Backdrop, 1 },
+                { ImageType.Backdrop, 2 },
+                { ImageType.Screenshot, 1 }
+            };
+
+            return theoryTypes;
+        }
+
+        [Theory]
+        [MemberData(nameof(GetImageTypesWithCount))]
+        public void ValidateImages_EmptyItemAndPopulatedProviders_AddsImages(ImageType imageType, int imageCount)
+        {
+            // Has to exist for querying DateModified time on file, results stored but not checked so not populating
+            BaseItem.FileSystem = Mock.Of<IFileSystem>();
+
+            var item = new MovieWithScreenshots();
+            var imageProvider = GetImageProvider(imageType, imageCount, true);
+
+            var itemImageProvider = GetItemImageProvider(null, null);
+            var changed = itemImageProvider.ValidateImages(item, new List<ILocalImageProvider> { imageProvider }, null);
+
+            Assert.True(changed);
+            Assert.Equal(imageCount, item.GetImages(imageType).Count());
+        }
+
+        [Theory]
+        [MemberData(nameof(GetImageTypesWithCount))]
+        public void ValidateImages_PopulatedItemWithGoodPathsAndEmptyProviders_NoChange(ImageType imageType, int imageCount)
+        {
+            var item = GetItemWithImages(imageType, imageCount, true);
+
+            var itemImageProvider = GetItemImageProvider(null, null);
+            var changed = itemImageProvider.ValidateImages(item, new List<ILocalImageProvider>(), null);
+
+            Assert.False(changed);
+            Assert.Equal(imageCount, item.GetImages(imageType).Count());
+        }
+
+        [Theory]
+        [MemberData(nameof(GetImageTypesWithCount))]
+        public void ValidateImages_PopulatedItemWithBadPathsAndEmptyProviders_RemovesImage(ImageType imageType, int imageCount)
+        {
+            var item = GetItemWithImages(imageType, imageCount, false);
+
+            var itemImageProvider = GetItemImageProvider(null, null);
+            var changed = itemImageProvider.ValidateImages(item, new List<ILocalImageProvider>(), null);
+
+            Assert.True(changed);
+            Assert.Empty(item.GetImages(imageType));
+        }
+
+        [Fact]
+        public void MergeImages_EmptyItemNewImagesEmpty_NoChange()
+        {
+            var itemImageProvider = GetItemImageProvider(null, null);
+            var changed = itemImageProvider.MergeImages(new MovieWithScreenshots(), new List<LocalImageInfo>());
+
+            Assert.False(changed);
+        }
+
+        [Theory]
+        [MemberData(nameof(GetImageTypesWithCount))]
+        public void MergeImages_PopulatedItemWithGoodPathsAndPopulatedNewImages_AddsUpdatesImages(ImageType imageType, int imageCount)
+        {
+            // valid and not valid paths - should replace the valid paths with the invalid ones
+            var item = GetItemWithImages(imageType, imageCount, true);
+            var images = GetImages(imageType, imageCount, false);
+
+            var itemImageProvider = GetItemImageProvider(null, null);
+            var changed = itemImageProvider.MergeImages(item, images);
+
+            Assert.True(changed);
+            // adds for types that allow multiple, replaces singular type images
+            if (item.AllowsMultipleImages(imageType))
+            {
+                Assert.Equal(imageCount * 2, item.GetImages(imageType).Count());
+            }
+            else
+            {
+                Assert.Single(item.GetImages(imageType));
+                Assert.Same(images[0].FileInfo.FullName, item.GetImages(imageType).First().Path);
+            }
+        }
+
+        [Theory]
+        [MemberData(nameof(GetImageTypesWithCount))]
+        public void MergeImages_PopulatedItemWithGoodPathsAndSameNewImages_NoChange(ImageType imageType, int imageCount)
+        {
+            var oldTime = new DateTime(1970, 1, 1);
+
+            // match update time with time added to item images (unix epoch)
+            var fileSystem = new Mock<IFileSystem>();
+            fileSystem.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>()))
+                .Returns(oldTime);
+            BaseItem.FileSystem = fileSystem.Object;
+
+            // all valid paths - matching for strictly updating
+            var item = GetItemWithImages(imageType, imageCount, true);
+            // set size to non-zero to allow for updates to occur
+            foreach (var image in item.GetImages(imageType))
+            {
+                image.DateModified = oldTime;
+                image.Height = 1;
+                image.Width = 1;
+            }
+
+            var images = GetImages(imageType, imageCount, true);
+
+            var itemImageProvider = GetItemImageProvider(null, fileSystem);
+            var changed = itemImageProvider.MergeImages(item, images);
+
+            Assert.False(changed);
+        }
+
+        [Theory]
+        [MemberData(nameof(GetImageTypesWithCount))]
+        public void MergeImages_PopulatedItemWithGoodPathsAndSameNewImagesWithNewTimestamps_ResetsImageSizes(ImageType imageType, int imageCount)
+        {
+            var oldTime = new DateTime(1970, 1, 1);
+            var updatedTime = new DateTime(2021, 1, 1);
+
+            var fileSystem = new Mock<IFileSystem>();
+            fileSystem.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>()))
+                .Returns(updatedTime);
+            BaseItem.FileSystem = fileSystem.Object;
+
+            // all valid paths - matching for strictly updating
+            var item = GetItemWithImages(imageType, imageCount, true);
+            // set size to non-zero to allow for image size reset to occur
+            foreach (var image in item.GetImages(imageType))
+            {
+                image.DateModified = oldTime;
+                image.Height = 1;
+                image.Width = 1;
+            }
+
+            var images = GetImages(imageType, imageCount, true);
+
+            var itemImageProvider = GetItemImageProvider(null, fileSystem);
+            var changed = itemImageProvider.MergeImages(item, images);
+
+            Assert.True(changed);
+            // before and after paths are the same, verify updated by size reset to 0
+            Assert.Equal(imageCount, item.GetImages(imageType).Count());
+            foreach (var image in item.GetImages(imageType))
+            {
+                Assert.Equal(updatedTime, image.DateModified);
+                Assert.Equal(0, image.Height);
+                Assert.Equal(0, image.Width);
+            }
+        }
+
+        [Theory]
+        [InlineData(ImageType.Primary, 1, false)]
+        [InlineData(ImageType.Backdrop, 2, false)]
+        [InlineData(ImageType.Screenshot, 2, false)]
+        [InlineData(ImageType.Primary, 1, true)]
+        [InlineData(ImageType.Backdrop, 2, true)]
+        [InlineData(ImageType.Screenshot, 2, true)]
+        public async void RefreshImages_PopulatedItemPopulatedProviderDynamic_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh)
+        {
+            var item = GetItemWithImages(imageType, imageCount, false);
+
+            var libraryOptions = GetLibraryOptions(item, imageType, imageCount);
+
+            var imageResponse = new DynamicImageResponse
+            {
+                HasImage = true,
+                Format = ImageFormat.Jpg,
+                Path = "url path",
+                Protocol = MediaProtocol.Http
+            };
+
+            var dynamicProvider = new Mock<IDynamicImageProvider>(MockBehavior.Strict);
+            dynamicProvider.Setup(rp => rp.Name).Returns("MockDynamicProvider");
+            dynamicProvider.Setup(rp => rp.GetSupportedImages(item))
+                .Returns(new[] { imageType });
+            dynamicProvider.Setup(rp => rp.GetImage(item, imageType, It.IsAny<CancellationToken>()))
+                .ReturnsAsync(imageResponse);
+
+            var refreshOptions = forceRefresh
+                ? new ImageRefreshOptions(null)
+                {
+                    ImageRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceAllImages = true
+                }
+                : new ImageRefreshOptions(null);
+
+            var itemImageProvider = GetItemImageProvider(null, new Mock<IFileSystem>());
+            var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { dynamicProvider.Object }, refreshOptions, CancellationToken.None);
+
+            Assert.Equal(forceRefresh, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate));
+            if (forceRefresh)
+            {
+                // replaces multi-types
+                Assert.Single(item.GetImages(imageType));
+            }
+            else
+            {
+                // adds to multi-types if room
+                Assert.Equal(imageCount, item.GetImages(imageType).Count());
+            }
+        }
+
+        [Theory]
+        [InlineData(ImageType.Primary, 1, true, MediaProtocol.Http)]
+        [InlineData(ImageType.Backdrop, 2, true, MediaProtocol.Http)]
+        [InlineData(ImageType.Primary, 1, true, MediaProtocol.File)]
+        [InlineData(ImageType.Backdrop, 2, true, MediaProtocol.File)]
+        [InlineData(ImageType.Primary, 1, false, MediaProtocol.File)]
+        [InlineData(ImageType.Backdrop, 2, false, MediaProtocol.File)]
+        public async void RefreshImages_EmptyItemPopulatedProviderDynamic_AddsImages(ImageType imageType, int imageCount, bool responseHasPath, MediaProtocol protocol)
+        {
+            // Has to exist for querying DateModified time on file, results stored but not checked so not populating
+            BaseItem.FileSystem = Mock.Of<IFileSystem>();
+
+            var item = new MovieWithScreenshots();
+
+            var libraryOptions = GetLibraryOptions(item, imageType, imageCount);
+
+            // Path must exist if set: is read in as a stream by AsyncFile.OpenRead
+            var imageResponse = new DynamicImageResponse
+            {
+                HasImage = true,
+                Format = ImageFormat.Jpg,
+                Path = responseHasPath ? string.Format(CultureInfo.InvariantCulture, TestDataImagePath, 0) : null,
+                Protocol = protocol
+            };
+
+            var dynamicProvider = new Mock<IDynamicImageProvider>(MockBehavior.Strict);
+            dynamicProvider.Setup(rp => rp.Name).Returns("MockDynamicProvider");
+            dynamicProvider.Setup(rp => rp.GetSupportedImages(item))
+                .Returns(new[] { imageType });
+            dynamicProvider.Setup(rp => rp.GetImage(item, imageType, It.IsAny<CancellationToken>()))
+                .ReturnsAsync(imageResponse);
+
+            var refreshOptions = new ImageRefreshOptions(null);
+
+            var providerManager = new Mock<IProviderManager>(MockBehavior.Strict);
+            providerManager.Setup(pm => pm.SaveImage(item, It.IsAny<Stream>(), It.IsAny<string>(), imageType, null, It.IsAny<CancellationToken>()))
+                .Callback<BaseItem, Stream, string, ImageType, int?, CancellationToken>((callbackItem, _, _, callbackType, _, _) => callbackItem.SetImagePath(callbackType, 0, new FileSystemMetadata()))
+                .Returns(Task.CompletedTask);
+            var itemImageProvider = GetItemImageProvider(providerManager.Object, null);
+            var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { dynamicProvider.Object }, refreshOptions, CancellationToken.None);
+
+            Assert.True(result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate));
+            // dynamic provider unable to return multiple images
+            Assert.Single(item.GetImages(imageType));
+            if (protocol == MediaProtocol.Http)
+            {
+                Assert.Equal(imageResponse.Path, item.GetImagePath(imageType, 0));
+            }
+        }
+
+        [Theory]
+        [InlineData(ImageType.Primary, 1, false)]
+        [InlineData(ImageType.Backdrop, 1, false)]
+        [InlineData(ImageType.Backdrop, 2, false)]
+        [InlineData(ImageType.Screenshot, 2, false)]
+        [InlineData(ImageType.Primary, 1, true)]
+        [InlineData(ImageType.Backdrop, 1, true)]
+        [InlineData(ImageType.Backdrop, 2, true)]
+        [InlineData(ImageType.Screenshot, 2, true)]
+        public async void RefreshImages_PopulatedItemPopulatedProviderRemote_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh)
+        {
+            var item = GetItemWithImages(imageType, imageCount, false);
+
+            var libraryOptions = GetLibraryOptions(item, imageType, imageCount);
+
+            var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict);
+            remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider");
+            remoteProvider.Setup(rp => rp.GetSupportedImages(item))
+                .Returns(new[] { imageType });
+
+            var refreshOptions = forceRefresh
+                ? new ImageRefreshOptions(null)
+                {
+                    ImageRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceAllImages = true
+                }
+                : new ImageRefreshOptions(null);
+
+            var remoteInfo = new List<RemoteImageInfo>();
+            for (int i = 0; i < imageCount; i++)
+            {
+                remoteInfo.Add(new RemoteImageInfo
+                {
+                    Type = imageType,
+                    Url = "image url " + i,
+                    Width = 1 // min width is set to 0, this will always pass
+                });
+            }
+
+            var providerManager = new Mock<IProviderManager>(MockBehavior.Strict);
+            providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny<BaseItem>(), It.IsAny<RemoteImageQuery>(), It.IsAny<CancellationToken>()))
+                .ReturnsAsync(remoteInfo);
+            var itemImageProvider = GetItemImageProvider(providerManager.Object, new Mock<IFileSystem>());
+            var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, refreshOptions, CancellationToken.None);
+
+            Assert.Equal(forceRefresh, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate));
+            Assert.Equal(imageCount, item.GetImages(imageType).Count());
+            foreach (var image in item.GetImages(imageType))
+            {
+                if (forceRefresh)
+                {
+                    Assert.Matches(@"image url [0-9]", image.Path);
+                }
+                else
+                {
+                    Assert.DoesNotMatch(@"image url [0-9]", image.Path);
+                }
+            }
+        }
+
+        [Theory]
+        [InlineData(ImageType.Primary, 0, false)] // singular type only fetches if type is missing from item, no caching
+        [InlineData(ImageType.Backdrop, 0, false)] // empty item, no cache to check
+        [InlineData(ImageType.Backdrop, 1, false)] // populated item, cached so no download
+        [InlineData(ImageType.Backdrop, 1, true)] // populated item, forced to download
+        public async void RefreshImages_NonStubItemPopulatedProviderRemote_DownloadsIfNecessary(ImageType imageType, int initialImageCount, bool fullRefresh)
+        {
+            var targetImageCount = 1;
+
+            // Set path and media source manager so images will be downloaded (EnableImageStub will return false)
+            var item = GetItemWithImages(imageType, initialImageCount, false);
+            item.Path = "non-empty path";
+            BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+            // seek 2 so it won't short-circuit out of downloading when populated
+            var libraryOptions = GetLibraryOptions(item, imageType, 2);
+
+            var content = "Content";
+            var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict);
+            remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider");
+            remoteProvider.Setup(rp => rp.GetSupportedImages(item))
+                .Returns(new[] { imageType });
+            remoteProvider.Setup(rp => rp.GetImageResponse(It.IsAny<string>(), It.IsAny<CancellationToken>()))
+                .ReturnsAsync((string url, CancellationToken _) => new HttpResponseMessage
+                {
+                    ReasonPhrase = url,
+                    StatusCode = HttpStatusCode.OK,
+                    Content = new StringContent(content, Encoding.UTF8, "image/jpeg")
+                });
+
+            var refreshOptions = fullRefresh
+                ? new ImageRefreshOptions(null)
+                {
+                    ImageRefreshMode = MetadataRefreshMode.FullRefresh,
+                    ReplaceAllImages = true
+                }
+                : new ImageRefreshOptions(null);
+
+            var remoteInfo = new List<RemoteImageInfo>();
+            for (int i = 0; i < targetImageCount; i++)
+            {
+                remoteInfo.Add(new RemoteImageInfo
+                {
+                    Type = imageType,
+                    Url = "image url " + i,
+                    Width = 1 // min width is set to 0, this will always pass
+                });
+            }
+
+            var providerManager = new Mock<IProviderManager>(MockBehavior.Strict);
+            providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny<BaseItem>(), It.IsAny<RemoteImageQuery>(), It.IsAny<CancellationToken>()))
+                .ReturnsAsync(remoteInfo);
+            providerManager.Setup(pm => pm.SaveImage(item, It.IsAny<Stream>(), It.IsAny<string>(), imageType, null, It.IsAny<CancellationToken>()))
+                .Callback<BaseItem, Stream, string, ImageType, int?, CancellationToken>((callbackItem, _, _, callbackType, _, _) =>
+                    callbackItem.SetImagePath(callbackType, callbackItem.AllowsMultipleImages(callbackType) ? callbackItem.GetImages(callbackType).Count() : 0, new FileSystemMetadata()))
+                .Returns(Task.CompletedTask);
+            var fileSystem = new Mock<IFileSystem>();
+            // match reported file size to image content length - condition for skipping already downloaded multi-images
+            fileSystem.Setup(fs => fs.GetFileInfo(It.IsAny<string>()))
+                .Returns(new FileSystemMetadata { Length = content.Length });
+            var itemImageProvider = GetItemImageProvider(providerManager.Object, fileSystem);
+            var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, refreshOptions, CancellationToken.None);
+
+            Assert.Equal(initialImageCount == 0 || fullRefresh, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate));
+            Assert.Equal(targetImageCount, item.GetImages(imageType).Count());
+        }
+
+        [Theory]
+        [MemberData(nameof(GetImageTypesWithCount))]
+        public async void RefreshImages_EmptyItemPopulatedProviderRemoteExtras_LimitsImages(ImageType imageType, int imageCount)
+        {
+            var item = new MovieWithScreenshots();
+
+            var libraryOptions = GetLibraryOptions(item, imageType, imageCount);
+
+            var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict);
+            remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider");
+            remoteProvider.Setup(rp => rp.GetSupportedImages(item))
+                .Returns(new[] { imageType });
+
+            var refreshOptions = new ImageRefreshOptions(null);
+
+            // populate remote with double the required images to verify count is trimmed to the library option count
+            var remoteInfo = new List<RemoteImageInfo>();
+            for (int i = 0; i < imageCount * 2; i++)
+            {
+                remoteInfo.Add(new RemoteImageInfo
+                {
+                    Type = imageType,
+                    Url = "image url " + i,
+                    Width = 1 // min width is set to 0, this will always pass
+                });
+            }
+
+            var providerManager = new Mock<IProviderManager>(MockBehavior.Strict);
+            providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny<BaseItem>(), It.IsAny<RemoteImageQuery>(), It.IsAny<CancellationToken>()))
+                .ReturnsAsync(remoteInfo);
+            var itemImageProvider = GetItemImageProvider(providerManager.Object, null);
+            var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, refreshOptions, CancellationToken.None);
+
+            Assert.True(result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate));
+            var actualImages = item.GetImages(imageType).ToList();
+            Assert.Equal(imageCount, actualImages.Count);
+            // images from the provider manager are sorted by preference (earlier images are higher priority) so we can verify that low url numbers are chosen
+            foreach (var image in actualImages)
+            {
+                var index = int.Parse(Regex.Match(image.Path, @"[0-9]+").Value, NumberStyles.Integer, CultureInfo.InvariantCulture);
+                Assert.True(index < imageCount);
+            }
+        }
+
+        [Theory]
+        [MemberData(nameof(GetImageTypesWithCount))]
+        public async void RefreshImages_PopulatedItemEmptyProviderRemoteFullRefresh_DoesntClearImages(ImageType imageType, int imageCount)
+        {
+            var item = GetItemWithImages(imageType, imageCount, false);
+
+            var libraryOptions = GetLibraryOptions(item, imageType, imageCount);
+
+            var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict);
+            remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider");
+            remoteProvider.Setup(rp => rp.GetSupportedImages(item))
+                .Returns(new[] { imageType });
+
+            var refreshOptions = new ImageRefreshOptions(null)
+            {
+                ImageRefreshMode = MetadataRefreshMode.FullRefresh,
+                ReplaceAllImages = true
+            };
+
+            var itemImageProvider = GetItemImageProvider(Mock.Of<IProviderManager>(), null);
+            var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, refreshOptions, CancellationToken.None);
+
+            Assert.False(result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate));
+            Assert.Equal(imageCount, item.GetImages(imageType).Count());
+        }
+
+        private static ItemImageProvider GetItemImageProvider(IProviderManager? providerManager, Mock<IFileSystem>? mockFileSystem)
+        {
+            // strict to ensure this isn't accidentally used where a prepared mock is intended
+            providerManager ??= Mock.Of<IProviderManager>(MockBehavior.Strict);
+
+            // BaseItem.ValidateImages depends on the directory service being able to list directory contents, give it the expected valid file paths
+            mockFileSystem ??= new Mock<IFileSystem>(MockBehavior.Strict);
+            mockFileSystem.Setup(fs => fs.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()))
+                .Returns(new[]
+                {
+                    string.Format(CultureInfo.InvariantCulture, TestDataImagePath, 0),
+                    string.Format(CultureInfo.InvariantCulture, TestDataImagePath, 1)
+                });
+
+            return new ItemImageProvider(new NullLogger<ItemImageProvider>(), providerManager, mockFileSystem.Object);
+        }
+
+        private static BaseItem GetItemWithImages(ImageType type, int count, bool validPaths)
+        {
+            // Has to exist for querying DateModified time on file, results stored but not checked so not populating
+            BaseItem.FileSystem ??= Mock.Of<IFileSystem>();
+
+            var item = new MovieWithScreenshots();
+
+            var path = validPaths ? TestDataImagePath : "invalid path {0}";
+            for (int i = 0; i < count; i++)
+            {
+                item.SetImagePath(type, i, new FileSystemMetadata
+                {
+                    FullName = string.Format(CultureInfo.InvariantCulture, path, i),
+                });
+            }
+
+            return item;
+        }
+
+        private static ILocalImageProvider GetImageProvider(ImageType type, int count, bool validPaths)
+        {
+            var images = GetImages(type, count, validPaths);
+
+            var imageProvider = new Mock<ILocalImageProvider>();
+            imageProvider.Setup(ip => ip.GetImages(It.IsAny<BaseItem>(), It.IsAny<IDirectoryService>()))
+                .Returns(images);
+            return imageProvider.Object;
+        }
+
+        /// <summary>
+        /// Creates a list of <see cref="LocalImageInfo"/> references of the specified type and size, optionally pointing to files that exist.
+        /// </summary>
+        private static List<LocalImageInfo> GetImages(ImageType type, int count, bool validPaths)
+        {
+            var path = validPaths ? TestDataImagePath : "invalid path {0}";
+            var images = new List<LocalImageInfo>(count);
+            for (int i = 0; i < count; i++)
+            {
+                images.Add(new LocalImageInfo
+                {
+                    Type = type,
+                    FileInfo = new FileSystemMetadata
+                    {
+                        FullName = string.Format(CultureInfo.InvariantCulture, path, i)
+                    }
+                });
+            }
+
+            return images;
+        }
+
+        /// <summary>
+        /// Generates a <see cref="LibraryOptions"/> object that will allow for the requested number of images for the target type.
+        /// </summary>
+        private static LibraryOptions GetLibraryOptions(BaseItem item, ImageType type, int count)
+        {
+            return new LibraryOptions
+            {
+                TypeOptions = new[]
+                {
+                    new TypeOptions
+                    {
+                        Type = item.GetType().Name,
+                        ImageOptions = new[]
+                        {
+                            new ImageOption
+                            {
+                                Type = type,
+                                Limit = count,
+                                MinWidth = 0
+                            }
+                        }
+                    }
+                }
+            };
+        }
+
+        // Create a class that implements IHasScreenshots for testing since no BaseItem class is also IHasScreenshots
+        private class MovieWithScreenshots : Movie, IHasScreenshots
+        {
+            // No contents
+        }
+    }
+}

+ 0 - 0
tests/Jellyfin.Providers.Tests/Test Data/Images/blank0.jpg


+ 0 - 0
tests/Jellyfin.Providers.Tests/Test Data/Images/blank1.jpg


+ 1 - 1
tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs

@@ -40,7 +40,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
             await localizationManager.LoadAll();
             await localizationManager.LoadAll();
             var cultures = localizationManager.GetCultures().ToList();
             var cultures = localizationManager.GetCultures().ToList();
 
 
-            Assert.Equal(189, cultures.Count);
+            Assert.Equal(190, cultures.Count);
 
 
             var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal));
             var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal));
             Assert.NotNull(germany);
             Assert.NotNull(germany);

+ 4 - 6
tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs

@@ -3,7 +3,6 @@ using System.Collections.Concurrent;
 using System.IO;
 using System.IO;
 using System.Threading;
 using System.Threading;
 using Emby.Server.Implementations;
 using Emby.Server.Implementations;
-using Emby.Server.Implementations.IO;
 using MediaBrowser.Common;
 using MediaBrowser.Common;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Mvc.Testing;
 using Microsoft.AspNetCore.Mvc.Testing;
@@ -67,7 +66,7 @@ namespace Jellyfin.Server.Integration.Tests
             var startupConfig = Program.CreateAppConfiguration(commandLineOpts, appPaths);
             var startupConfig = Program.CreateAppConfiguration(commandLineOpts, appPaths);
 
 
             ILoggerFactory loggerFactory = new SerilogLoggerFactory();
             ILoggerFactory loggerFactory = new SerilogLoggerFactory();
-            var serviceCollection = new ServiceCollection();
+
             _disposableComponents.Add(loggerFactory);
             _disposableComponents.Add(loggerFactory);
 
 
             // Create the app host and initialize it
             // Create the app host and initialize it
@@ -75,11 +74,10 @@ namespace Jellyfin.Server.Integration.Tests
                 appPaths,
                 appPaths,
                 loggerFactory,
                 loggerFactory,
                 commandLineOpts,
                 commandLineOpts,
-                new ConfigurationBuilder().Build(),
-                new ManagedFileSystem(loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths),
-                serviceCollection);
+                new ConfigurationBuilder().Build());
             _disposableComponents.Add(appHost);
             _disposableComponents.Add(appHost);
-            appHost.Init();
+            var serviceCollection = new ServiceCollection();
+            appHost.Init(serviceCollection);
 
 
             // Configure the web host builder
             // Configure the web host builder
             Program.ConfigureWebHostBuilder(builder, appHost, serviceCollection, commandLineOpts, startupConfig, appPaths);
             Program.ConfigureWebHostBuilder(builder, appHost, serviceCollection, commandLineOpts, startupConfig, appPaths);

+ 2 - 10
tests/Jellyfin.Server.Integration.Tests/TestAppHost.cs

@@ -2,9 +2,7 @@ using System.Collections.Generic;
 using System.Reflection;
 using System.Reflection;
 using Emby.Server.Implementations;
 using Emby.Server.Implementations;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller;
-using MediaBrowser.Model.IO;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 
 
 namespace Jellyfin.Server.Integration.Tests
 namespace Jellyfin.Server.Integration.Tests
@@ -21,22 +19,16 @@ namespace Jellyfin.Server.Integration.Tests
         /// <param name="loggerFactory">The <see cref="ILoggerFactory" /> to be used by the <see cref="CoreAppHost" />.</param>
         /// <param name="loggerFactory">The <see cref="ILoggerFactory" /> to be used by the <see cref="CoreAppHost" />.</param>
         /// <param name="options">The <see cref="StartupOptions" /> to be used by the <see cref="CoreAppHost" />.</param>
         /// <param name="options">The <see cref="StartupOptions" /> to be used by the <see cref="CoreAppHost" />.</param>
         /// <param name="startup">The <see cref="IConfiguration" /> to be used by the <see cref="CoreAppHost" />.</param>
         /// <param name="startup">The <see cref="IConfiguration" /> to be used by the <see cref="CoreAppHost" />.</param>
-        /// <param name="fileSystem">The <see cref="IFileSystem" /> to be used by the <see cref="CoreAppHost" />.</param>
-        /// <param name="collection">The <see cref="IServiceCollection"/> to be used by the <see cref="CoreAppHost"/>.</param>
         public TestAppHost(
         public TestAppHost(
             IServerApplicationPaths applicationPaths,
             IServerApplicationPaths applicationPaths,
             ILoggerFactory loggerFactory,
             ILoggerFactory loggerFactory,
             IStartupOptions options,
             IStartupOptions options,
-            IConfiguration startup,
-            IFileSystem fileSystem,
-            IServiceCollection collection)
+            IConfiguration startup)
             : base(
             : base(
                 applicationPaths,
                 applicationPaths,
                 loggerFactory,
                 loggerFactory,
                 options,
                 options,
-                startup,
-                fileSystem,
-                collection)
+                startup)
         {
         {
         }
         }