Browse Source

Merge pull request #7039 from 1337joe/providermanager-cleanup

Bond-009 2 years ago
parent
commit
f369ddf522

+ 6 - 8
MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs

@@ -35,7 +35,7 @@ namespace MediaBrowser.Controller.BaseItemManager
         public SemaphoreSlim MetadataRefreshThrottler { get; private set; }
 
         /// <inheritdoc />
-        public bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name)
+        public bool IsMetadataFetcherEnabled(BaseItem baseItem, TypeOptions? libraryTypeOptions, string name)
         {
             if (baseItem is Channel)
             {
@@ -49,10 +49,9 @@ namespace MediaBrowser.Controller.BaseItemManager
                 return !baseItem.EnableMediaSourceDisplay;
             }
 
-            var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name);
-            if (typeOptions != null)
+            if (libraryTypeOptions != null)
             {
-                return typeOptions.MetadataFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase);
+                return libraryTypeOptions.MetadataFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase);
             }
 
             var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, baseItem.GetType().Name, StringComparison.OrdinalIgnoreCase));
@@ -61,7 +60,7 @@ namespace MediaBrowser.Controller.BaseItemManager
         }
 
         /// <inheritdoc />
-        public bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name)
+        public bool IsImageFetcherEnabled(BaseItem baseItem, TypeOptions? libraryTypeOptions, string name)
         {
             if (baseItem is Channel)
             {
@@ -75,10 +74,9 @@ namespace MediaBrowser.Controller.BaseItemManager
                 return !baseItem.EnableMediaSourceDisplay;
             }
 
-            var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name);
-            if (typeOptions != null)
+            if (libraryTypeOptions != null)
             {
-                return typeOptions.ImageFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase);
+                return libraryTypeOptions.ImageFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase);
             }
 
             var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, baseItem.GetType().Name, StringComparison.OrdinalIgnoreCase));

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

@@ -18,18 +18,18 @@ namespace MediaBrowser.Controller.BaseItemManager
         /// Is metadata fetcher enabled.
         /// </summary>
         /// <param name="baseItem">The base item.</param>
-        /// <param name="libraryOptions">The library options.</param>
+        /// <param name="libraryTypeOptions">The type options for <c>baseItem</c> from the library (if defined).</param>
         /// <param name="name">The metadata fetcher name.</param>
         /// <returns><c>true</c> if metadata fetcher is enabled, else false.</returns>
-        bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name);
+        bool IsMetadataFetcherEnabled(BaseItem baseItem, TypeOptions? libraryTypeOptions, string name);
 
         /// <summary>
         /// Is image fetcher enabled.
         /// </summary>
         /// <param name="baseItem">The base item.</param>
-        /// <param name="libraryOptions">The library options.</param>
+        /// <param name="libraryTypeOptions">The type options for <c>baseItem</c> from the library (if defined).</param>
         /// <param name="name">The image fetcher name.</param>
         /// <returns><c>true</c> if image fetcher is enabled, else false.</returns>
-        bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name);
+        bool IsImageFetcherEnabled(BaseItem baseItem, TypeOptions? libraryTypeOptions, string name);
     }
 }

+ 5 - 0
MediaBrowser.Controller/Providers/IMetadataService.cs

@@ -23,6 +23,11 @@ namespace MediaBrowser.Controller.Providers
         /// <returns><c>true</c> if this instance can refresh the specified item.</returns>
         bool CanRefresh(BaseItem item);
 
+        /// <summary>
+        /// Determines whether this instance primarily targets the specified type.
+        /// </summary>
+        /// <param name="type">The type.</param>
+        /// <returns><c>true</c> if this instance primarily targets the specified type.</returns>
         bool CanRefreshPrimary(Type type);
 
         /// <summary>

+ 18 - 0
MediaBrowser.Controller/Providers/IProviderManager.cs

@@ -131,6 +131,24 @@ namespace MediaBrowser.Controller.Providers
         /// <returns>IEnumerable{ImageProviderInfo}.</returns>
         IEnumerable<ImageProviderInfo> GetRemoteImageProviderInfo(BaseItem item);
 
+        /// <summary>
+        /// Gets the image providers for the provided item.
+        /// </summary>
+        /// <param name="item">The item.</param>
+        /// <param name="refreshOptions">The image refresh options.</param>
+        /// <returns>The image providers for the item.</returns>
+        IEnumerable<IImageProvider> GetImageProviders(BaseItem item, ImageRefreshOptions refreshOptions);
+
+        /// <summary>
+        /// Gets the metadata providers for the provided item.
+        /// </summary>
+        /// <param name="item">The item.</param>
+        /// <param name="libraryOptions">The library options.</param>
+        /// <typeparam name="T">The type of metadata provider.</typeparam>
+        /// <returns>The metadata providers.</returns>
+        IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions)
+            where T : BaseItem;
+
         /// <summary>
         /// Gets all metadata plugins.
         /// </summary>

+ 2 - 2
MediaBrowser.Providers/Manager/MetadataService.cs

@@ -94,7 +94,7 @@ namespace MediaBrowser.Providers.Manager
 
             var localImagesFailed = false;
 
-            var allImageProviders = ((ProviderManager)ProviderManager).GetImageProviders(item, refreshOptions).ToList();
+            var allImageProviders = ProviderManager.GetImageProviders(item, refreshOptions).ToList();
 
             if (refreshOptions.RemoveOldMetadata && refreshOptions.ReplaceAllImages)
             {
@@ -522,7 +522,7 @@ namespace MediaBrowser.Providers.Manager
         protected IEnumerable<IMetadataProvider> GetProviders(BaseItem item, LibraryOptions libraryOptions, MetadataRefreshOptions options, bool isFirstRefresh, bool requiresRefresh)
         {
             // Get providers to refresh
-            var providers = ((ProviderManager)ProviderManager).GetMetadataProviders<TItemType>(item, libraryOptions).ToList();
+            var providers = ProviderManager.GetMetadataProviders<TItemType>(item, libraryOptions).ToList();
 
             var metadataRefreshMode = options.MetadataRefreshMode;
 

+ 115 - 191
MediaBrowser.Providers/Manager/ProviderManager.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Concurrent;
 using System.Collections.Generic;
@@ -48,7 +46,7 @@ namespace MediaBrowser.Providers.Manager
     /// </summary>
     public class ProviderManager : IProviderManager, IDisposable
     {
-        private readonly object _refreshQueueLock = new object();
+        private readonly object _refreshQueueLock = new();
         private readonly ILogger<ProviderManager> _logger;
         private readonly IHttpClientFactory _httpClientFactory;
         private readonly ILibraryMonitor _libraryMonitor;
@@ -58,11 +56,11 @@ namespace MediaBrowser.Providers.Manager
         private readonly ISubtitleManager _subtitleManager;
         private readonly IServerConfigurationManager _configurationManager;
         private readonly IBaseItemManager _baseItemManager;
-        private readonly ConcurrentDictionary<Guid, double> _activeRefreshes = new ConcurrentDictionary<Guid, double>();
-        private readonly CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource();
-        private readonly SimplePriorityQueue<Tuple<Guid, MetadataRefreshOptions>> _refreshQueue =
-            new SimplePriorityQueue<Tuple<Guid, MetadataRefreshOptions>>();
+        private readonly ConcurrentDictionary<Guid, double> _activeRefreshes = new();
+        private readonly CancellationTokenSource _disposeCancellationTokenSource = new();
+        private readonly SimplePriorityQueue<Tuple<Guid, MetadataRefreshOptions>> _refreshQueue = new();
 
+        private IImageProvider[] _imageProviders = Array.Empty<IImageProvider>();
         private IMetadataService[] _metadataServices = Array.Empty<IMetadataService>();
         private IMetadataProvider[] _metadataProviders = Array.Empty<IMetadataProvider>();
         private IMetadataSaver[] _savers = Array.Empty<IMetadataSaver>();
@@ -105,15 +103,13 @@ namespace MediaBrowser.Providers.Manager
         }
 
         /// <inheritdoc/>
-        public event EventHandler<GenericEventArgs<BaseItem>> RefreshStarted;
+        public event EventHandler<GenericEventArgs<BaseItem>>? RefreshStarted;
 
         /// <inheritdoc/>
-        public event EventHandler<GenericEventArgs<BaseItem>> RefreshCompleted;
+        public event EventHandler<GenericEventArgs<BaseItem>>? RefreshCompleted;
 
         /// <inheritdoc/>
-        public event EventHandler<GenericEventArgs<Tuple<BaseItem, double>>> RefreshProgress;
-
-        private IImageProvider[] ImageProviders { get; set; }
+        public event EventHandler<GenericEventArgs<Tuple<BaseItem, double>>>? RefreshProgress;
 
         /// <inheritdoc/>
         public void AddParts(
@@ -123,8 +119,7 @@ namespace MediaBrowser.Providers.Manager
             IEnumerable<IMetadataSaver> metadataSavers,
             IEnumerable<IExternalId> externalIds)
         {
-            ImageProviders = imageProviders.ToArray();
-
+            _imageProviders = imageProviders.ToArray();
             _metadataServices = metadataServices.OrderBy(i => i.Order).ToArray();
             _metadataProviders = metadataProviders.ToArray();
             _externalIds = externalIds.OrderBy(i => i.ProviderName).ToArray();
@@ -138,26 +133,15 @@ namespace MediaBrowser.Providers.Manager
             var type = item.GetType();
 
             var service = _metadataServices.FirstOrDefault(current => current.CanRefreshPrimary(type));
+            service ??= _metadataServices.FirstOrDefault(current => current.CanRefresh(item));
 
             if (service == null)
             {
-                foreach (var current in _metadataServices)
-                {
-                    if (current.CanRefresh(item))
-                    {
-                        service = current;
-                        break;
-                    }
-                }
+                _logger.LogError("Unable to find a metadata service for item of type {TypeName}", item.GetType().Name);
+                return Task.FromResult(ItemUpdateType.None);
             }
 
-            if (service != null)
-            {
-                return service.RefreshMetadata(item, options, cancellationToken);
-            }
-
-            _logger.LogError("Unable to find a metadata service for item of type {TypeName}", item.GetType().Name);
-            return Task.FromResult(ItemUpdateType.None);
+            return service.RefreshMetadata(item, options, cancellationToken);
         }
 
         /// <inheritdoc/>
@@ -181,6 +165,10 @@ namespace MediaBrowser.Providers.Manager
                 {
                     contentType = "image/png";
                 }
+                else
+                {
+                    throw new HttpRequestException("Invalid image received: contentType not set.", null, response.StatusCode);
+                }
             }
 
             // TVDb will sometimes serve a rubbish 404 html page with a 200 OK code, because reasons...
@@ -309,53 +297,69 @@ namespace MediaBrowser.Providers.Manager
             return GetRemoteImageProviders(item, true).Select(i => new ImageProviderInfo(i.Name, i.GetSupportedImages(item).ToArray()));
         }
 
-        /// <summary>
-        /// Gets the image providers for the provided item.
-        /// </summary>
-        /// <param name="item">The item.</param>
-        /// <param name="refreshOptions">The image refresh options.</param>
-        /// <returns>The image providers for the item.</returns>
-        public IEnumerable<IImageProvider> GetImageProviders(BaseItem item, ImageRefreshOptions refreshOptions)
+        private IEnumerable<IRemoteImageProvider> GetRemoteImageProviders(BaseItem item, bool includeDisabled)
         {
-            return GetImageProviders(item, _libraryManager.GetLibraryOptions(item), GetMetadataOptions(item), refreshOptions, false);
+            var options = GetMetadataOptions(item);
+            var libraryOptions = _libraryManager.GetLibraryOptions(item);
+
+            return GetImageProvidersInternal(
+                item,
+                libraryOptions,
+                options,
+                new ImageRefreshOptions(new DirectoryService(_fileSystem)),
+                includeDisabled).OfType<IRemoteImageProvider>();
         }
 
-        private IEnumerable<IImageProvider> GetImageProviders(BaseItem item, LibraryOptions libraryOptions, MetadataOptions options, ImageRefreshOptions refreshOptions, bool includeDisabled)
+        /// <inheritdoc/>
+        public IEnumerable<IImageProvider> GetImageProviders(BaseItem item, ImageRefreshOptions refreshOptions)
         {
-            // Avoid implicitly captured closure
-            var currentOptions = options;
+            return GetImageProvidersInternal(item, _libraryManager.GetLibraryOptions(item), GetMetadataOptions(item), refreshOptions, false);
+        }
 
+        private IEnumerable<IImageProvider> GetImageProvidersInternal(BaseItem item, LibraryOptions libraryOptions, MetadataOptions options, ImageRefreshOptions refreshOptions, bool includeDisabled)
+        {
             var typeOptions = libraryOptions.GetTypeOptions(item.GetType().Name);
-            var typeFetcherOrder = typeOptions?.ImageFetcherOrder;
+            var fetcherOrder = typeOptions?.ImageFetcherOrder ?? options.ImageFetcherOrder;
 
-            return ImageProviders.Where(i => CanRefresh(i, item, libraryOptions, refreshOptions, includeDisabled))
-                .OrderBy(i =>
+            return _imageProviders.Where(i => CanRefreshImages(i, item, typeOptions, refreshOptions, includeDisabled))
+                .OrderBy(i => GetConfiguredOrder(fetcherOrder, i.Name))
+                .ThenBy(GetDefaultOrder);
+        }
+
+        private bool CanRefreshImages(
+            IImageProvider provider,
+            BaseItem item,
+            TypeOptions? libraryTypeOptions,
+            ImageRefreshOptions refreshOptions,
+            bool includeDisabled)
+        {
+            try
+            {
+                if (!provider.Supports(item))
                 {
-                    // See if there's a user-defined order
-                    if (i is not ILocalImageProvider)
-                    {
-                        var fetcherOrder = typeFetcherOrder ?? currentOptions.ImageFetcherOrder;
-                        var index = Array.IndexOf(fetcherOrder, i.Name);
+                    return false;
+                }
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "{ProviderName} failed in Supports for type {ItemType} at {ItemPath}", provider.GetType().Name, item.GetType().Name, item.Path);
+                return false;
+            }
 
-                        if (index != -1)
-                        {
-                            return index;
-                        }
-                    }
+            if (includeDisabled || provider is ILocalImageProvider)
+            {
+                return true;
+            }
 
-                    // Not configured. Just return some high number to put it at the end.
-                    return 100;
-                })
-            .ThenBy(GetOrder);
+            if (item.IsLocked && refreshOptions.ImageRefreshMode != MetadataRefreshMode.FullRefresh)
+            {
+                return false;
+            }
+
+            return _baseItemManager.IsImageFetcherEnabled(item, libraryTypeOptions, provider.Name);
         }
 
-        /// <summary>
-        /// Gets the metadata providers for the provided item.
-        /// </summary>
-        /// <param name="item">The item.</param>
-        /// <param name="libraryOptions">The library options.</param>
-        /// <typeparam name="T">The type of metadata provider.</typeparam>
-        /// <returns>The metadata providers.</returns>
+        /// <inheritdoc />
         public IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions)
             where T : BaseItem
         {
@@ -367,165 +371,84 @@ namespace MediaBrowser.Providers.Manager
         private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata)
             where T : BaseItem
         {
-            // Avoid implicitly captured closure
-            var currentOptions = globalMetadataOptions;
+            var localMetadataReaderOrder = libraryOptions.LocalMetadataReaderOrder ?? globalMetadataOptions.LocalMetadataReaderOrder;
+            var typeOptions = libraryOptions.GetTypeOptions(item.GetType().Name);
+            var metadataFetcherOrder = typeOptions?.MetadataFetcherOrder ?? globalMetadataOptions.MetadataFetcherOrder;
 
             return _metadataProviders.OfType<IMetadataProvider<T>>()
-                .Where(i => CanRefresh(i, item, libraryOptions, includeDisabled, forceEnableInternetMetadata))
-                .OrderBy(i => GetConfiguredOrder(item, i, libraryOptions, globalMetadataOptions))
+                .Where(i => CanRefreshMetadata(i, item, typeOptions, includeDisabled, forceEnableInternetMetadata))
+                .OrderBy(i =>
+                    // local and remote providers will be interleaved in the final order
+                    // only relative order within a type matters: consumers of the list filter to one or the other
+                    i switch
+                    {
+                        ILocalMetadataProvider => GetConfiguredOrder(localMetadataReaderOrder, i.Name),
+                        IRemoteMetadataProvider => GetConfiguredOrder(metadataFetcherOrder, i.Name),
+                        // Default to end
+                        _ => int.MaxValue
+                    })
                 .ThenBy(GetDefaultOrder);
         }
 
-        private IEnumerable<IRemoteImageProvider> GetRemoteImageProviders(BaseItem item, bool includeDisabled)
-        {
-            var options = GetMetadataOptions(item);
-            var libraryOptions = _libraryManager.GetLibraryOptions(item);
-
-            return GetImageProviders(
-                item,
-                libraryOptions,
-                options,
-                new ImageRefreshOptions(new DirectoryService(_fileSystem)),
-                includeDisabled).OfType<IRemoteImageProvider>();
-        }
-
-        private bool CanRefresh(
+        private bool CanRefreshMetadata(
             IMetadataProvider provider,
             BaseItem item,
-            LibraryOptions libraryOptions,
+            TypeOptions? libraryTypeOptions,
             bool includeDisabled,
             bool forceEnableInternetMetadata)
         {
-            if (!includeDisabled)
-            {
-                // If locked only allow local providers
-                if (item.IsLocked && provider is not ILocalMetadataProvider && provider is not IForcedProvider)
-                {
-                    return false;
-                }
-
-                if (provider is IRemoteMetadataProvider)
-                {
-                    if (!forceEnableInternetMetadata && !_baseItemManager.IsMetadataFetcherEnabled(item, libraryOptions, provider.Name))
-                    {
-                        return false;
-                    }
-                }
-            }
-
             if (!item.SupportsLocalMetadata && provider is ILocalMetadataProvider)
             {
                 return false;
             }
 
-            // If this restriction is ever lifted, movie xml providers will have to be updated to prevent owned items like trailers from reading those files
-            if (!item.OwnerId.Equals(default))
+            // Prevent owned items from reading the same local metadata file as their owner
+            if (!item.OwnerId.Equals(default) && provider is ILocalMetadataProvider)
             {
-                if (provider is ILocalMetadataProvider || provider is IRemoteMetadataProvider)
-                {
-                    return false;
-                }
+                return false;
             }
 
-            return true;
-        }
-
-        private bool CanRefresh(
-            IImageProvider provider,
-            BaseItem item,
-            LibraryOptions libraryOptions,
-            ImageRefreshOptions refreshOptions,
-            bool includeDisabled)
-        {
-            if (!includeDisabled)
+            if (includeDisabled)
             {
-                // If locked only allow local providers
-                if (item.IsLocked && provider is not ILocalImageProvider)
-                {
-                    if (refreshOptions.ImageRefreshMode != MetadataRefreshMode.FullRefresh)
-                    {
-                        return false;
-                    }
-                }
-
-                if (provider is IRemoteImageProvider || provider is IDynamicImageProvider)
-                {
-                    if (!_baseItemManager.IsImageFetcherEnabled(item, libraryOptions, provider.Name))
-                    {
-                        return false;
-                    }
-                }
+                return true;
             }
 
-            try
-            {
-                return provider.Supports(item);
-            }
-            catch (Exception ex)
+            // If locked only allow local providers
+            if (item.IsLocked && provider is not ILocalMetadataProvider && provider is not IForcedProvider)
             {
-                _logger.LogError(ex, "{ProviderName} failed in Supports for type {ItemType} at {ItemPath}", provider.GetType().Name, item.GetType().Name, item.Path);
                 return false;
             }
-        }
 
-        /// <summary>
-        /// Gets the order.
-        /// </summary>
-        /// <param name="provider">The provider.</param>
-        /// <returns>System.Int32.</returns>
-        private int GetOrder(IImageProvider provider)
-        {
-            if (provider is not IHasOrder hasOrder)
+            if (forceEnableInternetMetadata || provider is not IRemoteMetadataProvider)
             {
-                return 0;
+                return true;
             }
 
-            return hasOrder.Order;
+            return _baseItemManager.IsMetadataFetcherEnabled(item, libraryTypeOptions, provider.Name);
         }
 
-        private int GetConfiguredOrder(BaseItem item, IMetadataProvider provider, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions)
+        private static int GetConfiguredOrder(string[] order, string providerName)
         {
-            // See if there's a user-defined order
-            if (provider is ILocalMetadataProvider)
-            {
-                var configuredOrder = libraryOptions.LocalMetadataReaderOrder ?? globalMetadataOptions.LocalMetadataReaderOrder;
-
-                var index = Array.IndexOf(configuredOrder, provider.Name);
-
-                if (index != -1)
-                {
-                    return index;
-                }
-            }
+            var index = Array.IndexOf(order, providerName);
 
-            // See if there's a user-defined order
-            if (provider is IRemoteMetadataProvider)
+            if (index != -1)
             {
-                var typeOptions = libraryOptions.GetTypeOptions(item.GetType().Name);
-                var typeFetcherOrder = typeOptions?.MetadataFetcherOrder;
-
-                var fetcherOrder = typeFetcherOrder ?? globalMetadataOptions.MetadataFetcherOrder;
-
-                var index = Array.IndexOf(fetcherOrder, provider.Name);
-
-                if (index != -1)
-                {
-                    return index;
-                }
+                return index;
             }
 
-            // Not configured. Just return some high number to put it at the end.
-            return 100;
+            // default to end
+            return int.MaxValue;
         }
 
-        private int GetDefaultOrder(IMetadataProvider provider)
+        private static int GetDefaultOrder(object provider)
         {
             if (provider is IHasOrder hasOrder)
             {
                 return hasOrder.Order;
             }
 
-            return 0;
+            // after items that want to be first (~0) but before items that want to be last (~100)
+            return 50;
         }
 
         /// <inheritdoc/>
@@ -568,7 +491,7 @@ namespace MediaBrowser.Providers.Manager
 
             var libraryOptions = new LibraryOptions();
 
-            var imageProviders = GetImageProviders(
+            var imageProviders = GetImageProvidersInternal(
                 dummy,
                 libraryOptions,
                 options,
@@ -677,7 +600,7 @@ namespace MediaBrowser.Providers.Manager
 
             foreach (var saver in savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, updateType, false)))
             {
-                _logger.LogDebug("Saving {0} to {1}.", item.Path ?? item.Name, saver.Name);
+                _logger.LogDebug("Saving {Item} to {Saver}", item.Path ?? item.Name, saver.Name);
 
                 if (saver is IMetadataFileSaver fileSaver)
                 {
@@ -689,7 +612,7 @@ namespace MediaBrowser.Providers.Manager
                     }
                     catch (Exception ex)
                     {
-                        _logger.LogError(ex, "Error in {0} GetSavePath", saver.Name);
+                        _logger.LogError(ex, "Error in {Saver} GetSavePath", saver.Name);
                         continue;
                     }
 
@@ -776,7 +699,7 @@ namespace MediaBrowser.Providers.Manager
             }
             catch (Exception ex)
             {
-                _logger.LogError(ex, "Error in {0}.IsEnabledFor", saver.Name);
+                _logger.LogError(ex, "Error in {Saver}.IsEnabledFor", saver.Name);
                 return false;
             }
         }
@@ -786,7 +709,7 @@ namespace MediaBrowser.Providers.Manager
             where TItemType : BaseItem, new()
             where TLookupType : ItemLookupInfo
         {
-            BaseItem referenceItem = null;
+            BaseItem? referenceItem = null;
 
             if (!searchInfo.ItemId.Equals(default))
             {
@@ -796,7 +719,7 @@ namespace MediaBrowser.Providers.Manager
             return GetRemoteSearchResults<TItemType, TLookupType>(searchInfo, referenceItem, cancellationToken);
         }
 
-        private async Task<IEnumerable<RemoteSearchResult>> GetRemoteSearchResults<TItemType, TLookupType>(RemoteSearchQuery<TLookupType> searchInfo, BaseItem referenceItem, CancellationToken cancellationToken)
+        private async Task<IEnumerable<RemoteSearchResult>> GetRemoteSearchResults<TItemType, TLookupType>(RemoteSearchQuery<TLookupType> searchInfo, BaseItem? referenceItem, CancellationToken cancellationToken)
             where TItemType : BaseItem, new()
             where TLookupType : ItemLookupInfo
         {
@@ -926,7 +849,7 @@ namespace MediaBrowser.Providers.Manager
                 }
                 catch (Exception ex)
                 {
-                    _logger.LogError(ex, "Error in {0}.Supports", i.GetType().Name);
+                    _logger.LogError(ex, "Error in {Type}.Supports", i.GetType().Name);
                     return false;
                 }
             });
@@ -958,7 +881,8 @@ namespace MediaBrowser.Providers.Manager
                         i.UrlFormatString,
                         value)
                 };
-            }).Where(i => i != null).Concat(item.GetRelatedUrls());
+            }).Where(i => i != null)
+                .Concat(item.GetRelatedUrls())!; // We just filtered out all the nulls
         }
 
         /// <inheritdoc/>
@@ -991,7 +915,7 @@ namespace MediaBrowser.Providers.Manager
         /// <inheritdoc/>
         public void OnRefreshStart(BaseItem item)
         {
-            _logger.LogDebug("OnRefreshStart {0}", item.Id.ToString("N", CultureInfo.InvariantCulture));
+            _logger.LogDebug("OnRefreshStart {Item}", item.Id.ToString("N", CultureInfo.InvariantCulture));
             _activeRefreshes[item.Id] = 0;
             RefreshStarted?.Invoke(this, new GenericEventArgs<BaseItem>(item));
         }
@@ -999,7 +923,7 @@ namespace MediaBrowser.Providers.Manager
         /// <inheritdoc/>
         public void OnRefreshComplete(BaseItem item)
         {
-            _logger.LogDebug("OnRefreshComplete {0}", item.Id.ToString("N", CultureInfo.InvariantCulture));
+            _logger.LogDebug("OnRefreshComplete {Item}", item.Id.ToString("N", CultureInfo.InvariantCulture));
 
             _activeRefreshes.Remove(item.Id, out _);
 
@@ -1021,7 +945,7 @@ namespace MediaBrowser.Providers.Manager
         public void OnRefreshProgress(BaseItem item, double progress)
         {
             var id = item.Id;
-            _logger.LogDebug("OnRefreshProgress {0} {1}", id.ToString("N", CultureInfo.InvariantCulture), progress);
+            _logger.LogDebug("OnRefreshProgress {Id} {Progress}", id.ToString("N", CultureInfo.InvariantCulture), progress);
 
             // TODO: Need to hunt down the conditions for this happening
             _activeRefreshes.AddOrUpdate(

+ 12 - 20
tests/Jellyfin.Controller.Tests/BaseItemManagerTests.cs

@@ -20,17 +20,13 @@ namespace Jellyfin.Controller.Tests
         {
             BaseItem item = (BaseItem)Activator.CreateInstance(itemType)!;
 
-            var libraryOptions = new LibraryOptions
-            {
-                TypeOptions = new[]
+            var libraryTypeOptions = itemType == typeof(Book)
+                ? new TypeOptions
                 {
-                    new TypeOptions
-                    {
-                        Type = "Book",
-                        MetadataFetchers = new[] { "LibraryEnabled" }
-                    }
+                    Type = "Book",
+                    MetadataFetchers = new[] { "LibraryEnabled" }
                 }
-            };
+                : null;
 
             var serverConfiguration = new ServerConfiguration();
             foreach (var typeConfig in serverConfiguration.MetadataOptions)
@@ -43,7 +39,7 @@ namespace Jellyfin.Controller.Tests
                 .Returns(serverConfiguration);
 
             var baseItemManager = new BaseItemManager(serverConfigurationManager.Object);
-            var actual = baseItemManager.IsMetadataFetcherEnabled(item, libraryOptions, fetcherName);
+            var actual = baseItemManager.IsMetadataFetcherEnabled(item, libraryTypeOptions, fetcherName);
 
             Assert.Equal(expected, actual);
         }
@@ -57,17 +53,13 @@ namespace Jellyfin.Controller.Tests
         {
             BaseItem item = (BaseItem)Activator.CreateInstance(itemType)!;
 
-            var libraryOptions = new LibraryOptions
-            {
-                TypeOptions = new[]
+            var libraryTypeOptions = itemType == typeof(Book)
+                ? new TypeOptions
                 {
-                    new TypeOptions
-                    {
-                        Type = "Book",
-                        ImageFetchers = new[] { "LibraryEnabled" }
-                    }
+                    Type = "Book",
+                    ImageFetchers = new[] { "LibraryEnabled" }
                 }
-            };
+                : null;
 
             var serverConfiguration = new ServerConfiguration();
             foreach (var typeConfig in serverConfiguration.MetadataOptions)
@@ -80,7 +72,7 @@ namespace Jellyfin.Controller.Tests
                 .Returns(serverConfiguration);
 
             var baseItemManager = new BaseItemManager(serverConfigurationManager.Object);
-            var actual = baseItemManager.IsImageFetcherEnabled(item, libraryOptions, fetcherName);
+            var actual = baseItemManager.IsImageFetcherEnabled(item, libraryTypeOptions, fetcherName);
 
             Assert.Equal(expected, actual);
         }

+ 614 - 0
tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs

@@ -0,0 +1,614 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.BaseItemManager;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Providers.Manager;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+// Allow Moq to see internal class
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
+
+namespace Jellyfin.Providers.Tests.Manager
+{
+    public class ProviderManagerTests
+    {
+        private static readonly ILogger<ProviderManager> _logger = new NullLogger<ProviderManager>();
+
+        public static TheoryData<Mock<IMetadataService>[], int> RefreshSingleItemOrderData()
+            => new()
+            {
+                // no order set, uses provided order
+                {
+                    new[]
+                    {
+                        MockIMetadataService(true, true),
+                        MockIMetadataService(true, true)
+                    },
+                    0
+                },
+                // sort order sets priority when all match
+                {
+                    new[]
+                    {
+                        MockIMetadataService(true, true, 1),
+                        MockIMetadataService(true, true, 0),
+                        MockIMetadataService(true, true, 2)
+                    },
+                    1
+                },
+                // CanRefreshPrimary prioritized
+                {
+                    new[]
+                    {
+                        MockIMetadataService(false, true),
+                        MockIMetadataService(true, true),
+                    },
+                    1
+                },
+                // falls back to CanRefresh
+                {
+                    new[]
+                    {
+                        MockIMetadataService(false, false),
+                        MockIMetadataService(false, true)
+                    },
+                    1
+                },
+            };
+
+        [Theory]
+        [MemberData(nameof(RefreshSingleItemOrderData))]
+        public async Task RefreshSingleItem_ServiceOrdering_FollowsPriority(Mock<IMetadataService>[] servicesList, int expectedIndex)
+        {
+            var item = new Movie();
+
+            using var providerManager = GetProviderManager();
+            AddParts(providerManager, metadataServices: servicesList.Select(s => s.Object).ToArray());
+
+            var refreshOptions = new MetadataRefreshOptions(Mock.Of<IDirectoryService>(MockBehavior.Strict));
+            var actual = await providerManager.RefreshSingleItem(item, refreshOptions, CancellationToken.None).ConfigureAwait(false);
+
+            Assert.Equal(ItemUpdateType.MetadataDownload, actual);
+            for (var i = 0; i < servicesList.Length; i++)
+            {
+                var times = i == expectedIndex ? Times.Once() : Times.Never();
+                servicesList[i].Verify(mock => mock.RefreshMetadata(It.IsAny<BaseItem>(), It.IsAny<MetadataRefreshOptions>(), It.IsAny<CancellationToken>()), times);
+            }
+        }
+
+        [Theory]
+        [InlineData(true)]
+        [InlineData(false)]
+        public async Task RefreshSingleItem_RefreshMetadata_WhenServiceFound(bool serviceFound)
+        {
+            var item = new Movie();
+
+            var servicesList = new[] { MockIMetadataService(false, serviceFound) };
+
+            using var providerManager = GetProviderManager();
+            AddParts(providerManager, metadataServices: servicesList.Select(s => s.Object).ToArray());
+
+            var refreshOptions = new MetadataRefreshOptions(Mock.Of<IDirectoryService>(MockBehavior.Strict));
+            var actual = await providerManager.RefreshSingleItem(item, refreshOptions, CancellationToken.None).ConfigureAwait(false);
+
+            var expectedResult = serviceFound ? ItemUpdateType.MetadataDownload : ItemUpdateType.None;
+            Assert.Equal(expectedResult, actual);
+        }
+
+        public static TheoryData<int, int[]?, int[]?, int?[]?, int[]> GetImageProvidersOrderData()
+            => new()
+            {
+                { 3, null, null, null, new[] { 0, 1, 2 } }, // no order options set
+
+                // library options ordering
+                { 3, Array.Empty<int>(), null, null, new[] { 0, 1, 2 } }, // no order provided
+                { 3, new[] { 1 }, null, null, new[] { 1, 0, 2 } }, // one item in order
+                { 3, new[] { 2, 1, 0 }, null, null, new[] { 2, 1, 0 } }, // full reverse order
+
+                // server options ordering
+                { 3, null, Array.Empty<int>(), null, new[] { 0, 1, 2 } }, // no order provided
+                { 3, null, new[] { 1 }, null, new[] { 1, 0, 2 } }, // one item in order
+                { 3, null, new[] { 2, 1, 0 }, null, new[] { 2, 1, 0 } }, // full reverse order
+
+                // IHasOrder ordering
+                { 3, null, null, new int?[] { null, 1, null }, new[] { 1, 0, 2 } }, // one item with defined order
+                { 3, null, null, new int?[] { 2, 1, 0 }, new[] { 2, 1, 0 } }, // full reverse order
+
+                // multiple orders set
+                { 3, new[] { 1 }, new[] { 2, 0, 1 }, null, new[] { 1, 0, 2 } }, // partial library order first, server order ignored
+                { 3, new[] { 1 }, null, new int?[] { 2, 0, 1 }, new[] { 1, 2, 0 } }, // library order first, then orderby
+                { 3, new[] { 2, 1, 0 }, new[] { 1, 2, 0 }, new int?[] { 2, 0, 1 }, new[] { 2, 1, 0 } }, // library order wins
+            };
+
+        [Theory]
+        [MemberData(nameof(GetImageProvidersOrderData))]
+        public void GetImageProviders_ProviderOrder_MatchesExpected(int providerCount, int[]? libraryOrder, int[]? serverOrder, int?[]? hasOrderOrder, int[] expectedOrder)
+        {
+            var item = new Movie();
+
+            var nameProvider = new Func<int, string>(i => "Provider" + i);
+
+            var providerList = new List<IImageProvider>();
+            for (var i = 0; i < providerCount; i++)
+            {
+                var order = hasOrderOrder?[i];
+                providerList.Add(MockIImageProvider<ILocalImageProvider>(nameProvider(i), item, order: order));
+            }
+
+            var libraryOptions = CreateLibraryOptions(item.GetType().Name, imageFetcherOrder: libraryOrder?.Select(nameProvider).ToArray());
+            var serverConfiguration = CreateServerConfiguration(item.GetType().Name, imageFetcherOrder: serverOrder?.Select(nameProvider).ToArray());
+
+            using var providerManager = GetProviderManager(serverConfiguration: serverConfiguration, libraryOptions: libraryOptions);
+            AddParts(providerManager, imageProviders: providerList);
+
+            var refreshOptions = new ImageRefreshOptions(Mock.Of<IDirectoryService>(MockBehavior.Strict));
+            var actualProviders = providerManager.GetImageProviders(item, refreshOptions).ToList();
+
+            Assert.Equal(providerList.Count, actualProviders.Count);
+            var actualOrder = actualProviders.Select(i => providerList.IndexOf(i)).ToArray();
+            Assert.Equal(expectedOrder, actualOrder);
+        }
+
+        [Theory]
+        [InlineData(true, false, true)]
+        [InlineData(false, false, false)]
+        [InlineData(true, true, false)]
+        public void GetImageProviders_CanRefreshImagesBasic_WhenSupportsWithoutError(bool supports, bool errorOnSupported, bool expected)
+        {
+            GetImageProviders_CanRefreshImages_Tester(nameof(IImageProvider), supports, expected, errorOnSupported: errorOnSupported);
+        }
+
+        [Theory]
+        [InlineData(nameof(ILocalImageProvider), false, true)]
+        [InlineData(nameof(ILocalImageProvider), true, true)]
+        [InlineData(nameof(IImageProvider), false, false)]
+        [InlineData(nameof(IImageProvider), true, true)]
+        public void GetImageProviders_CanRefreshImagesLocked_WhenLocalOrFullRefresh(string providerType, bool fullRefresh, bool expected)
+        {
+            GetImageProviders_CanRefreshImages_Tester(providerType, true, expected, itemLocked: true, fullRefresh: fullRefresh);
+        }
+
+        [Theory]
+        [InlineData(nameof(ILocalImageProvider), false, true)]
+        [InlineData(nameof(IRemoteImageProvider), true, true)]
+        [InlineData(nameof(IDynamicImageProvider), true, true)]
+        [InlineData(nameof(IRemoteImageProvider), false, false)]
+        [InlineData(nameof(IDynamicImageProvider), false, false)]
+        public void GetImageProviders_CanRefreshImagesBaseItemEnabled_WhenLocalOrEnabled(string providerType, bool enabled, bool expected)
+        {
+            GetImageProviders_CanRefreshImages_Tester(providerType, true, expected, baseItemEnabled: enabled);
+        }
+
+        private static void GetImageProviders_CanRefreshImages_Tester(
+            string providerType,
+            bool supports,
+            bool expected,
+            bool errorOnSupported = false,
+            bool itemLocked = false,
+            bool fullRefresh = false,
+            bool baseItemEnabled = true)
+        {
+            var item = new Movie
+            {
+                IsLocked = itemLocked
+            };
+
+            var providerName = "provider";
+            IImageProvider provider = providerType switch
+            {
+                "IImageProvider" => MockIImageProvider<IImageProvider>(providerName, item, supports: supports, errorOnSupported: errorOnSupported),
+                "ILocalImageProvider" => MockIImageProvider<ILocalImageProvider>(providerName, item, supports: supports, errorOnSupported: errorOnSupported),
+                "IRemoteImageProvider" => MockIImageProvider<IRemoteImageProvider>(providerName, item, supports: supports, errorOnSupported: errorOnSupported),
+                "IDynamicImageProvider" => MockIImageProvider<IDynamicImageProvider>(providerName, item, supports: supports, errorOnSupported: errorOnSupported),
+                _ => throw new ArgumentException("Unexpected provider type")
+            };
+
+            var refreshOptions = new ImageRefreshOptions(Mock.Of<IDirectoryService>(MockBehavior.Strict))
+            {
+                ImageRefreshMode = fullRefresh ? MetadataRefreshMode.FullRefresh : MetadataRefreshMode.Default
+            };
+
+            var baseItemManager = new Mock<IBaseItemManager>(MockBehavior.Strict);
+            baseItemManager.Setup(i => i.IsImageFetcherEnabled(item, It.IsAny<TypeOptions>(), providerName))
+                .Returns(baseItemEnabled);
+
+            using var providerManager = GetProviderManager(baseItemManager: baseItemManager.Object);
+            AddParts(providerManager, imageProviders: new[] { provider });
+
+            var actualProviders = providerManager.GetImageProviders(item, refreshOptions).ToArray();
+
+            Assert.Equal(expected ? 1 : 0, actualProviders.Length);
+        }
+
+        public static TheoryData<string[], int[]?, int[]?, int[]?, int[]?, int?[]?, int[]> GetMetadataProvidersOrderData()
+        {
+            var l = nameof(ILocalMetadataProvider);
+            var r = nameof(IRemoteMetadataProvider);
+            return new()
+            {
+                { new[] { l, l, r, r }, null, null, null, null, null, new[] { 0, 1, 2, 3 } }, // no order options set
+
+                // library options ordering
+                { new[] { l, l, r, r }, Array.Empty<int>(), Array.Empty<int>(), null, null, null, new[] { 0, 1, 2, 3 } }, // no order provided
+                // local only
+                { new[] { r, l, l, l }, new[] { 2 }, null, null, null, null, new[] { 2, 0, 1, 3 } }, // one item in order
+                { new[] { r, l, l, l }, new[] { 3, 2, 1 }, null, null, null, null, new[] { 3, 2, 1, 0 } }, // full reverse order
+                // remote only
+                { new[] { l, r, r, r }, null, new[] { 2 }, null, null, null, new[] { 2, 0, 1, 3 } }, // one item in order
+                { new[] { l, r, r, r }, null, new[] { 3, 2, 1 }, null, null, null, new[] { 3, 2, 1, 0 } }, // full reverse order
+                // local and remote, note that results will be interleaved (odd but expected)
+                { new[] { l, l, r, r }, new[] { 1 }, new[] { 3 }, null, null, null, new[] { 1, 3, 0, 2 } }, // one item in each order
+                { new[] { l, l, l, r, r, r }, new[] { 2, 1, 0 }, new[] { 5, 4, 3 }, null, null, null, new[] { 2, 5, 1, 4, 0, 3 } }, // full reverse order
+
+                // // server options ordering
+                { new[] { l, l, r, r }, null, null, Array.Empty<int>(), Array.Empty<int>(), null, new[] { 0, 1, 2, 3 } }, // no order provided
+                // local only
+                { new[] { r, l, l, l }, null, null, new[] { 2 }, null, null, new[] { 2, 0, 1, 3 } }, // one item in order
+                { new[] { r, l, l, l }, null, null, new[] { 3, 2, 1 }, null, null, new[] { 3, 2, 1, 0 } }, // full reverse order
+                // remote only
+                { new[] { l, r, r, r }, null, null, null, new[] { 2 }, null, new[] { 2, 0, 1, 3 } }, // one item in order
+                { new[] { l, r, r, r }, null, null, null, new[] { 3, 2, 1 }, null, new[] { 3, 2, 1, 0 } }, // full reverse order
+                // local and remote, note that results will be interleaved (odd but expected)
+                { new[] { l, l, r, r }, null, null, new[] { 1 }, new[] { 3 }, null, new[] { 1, 3, 0, 2 } }, // one item in each order
+                { new[] { l, l, l, r, r, r }, null, null, new[] { 2, 1, 0 }, new[] { 5, 4, 3 }, null, new[] { 2, 5, 1, 4, 0, 3 } }, // full reverse order
+
+                // IHasOrder ordering (not interleaved, doesn't care about types)
+                { new[] { l, l, r, r }, null, null, null, null, new int?[] { 2, null, 1, null }, new[] { 2, 0, 1, 3 } }, // partially defined
+                { new[] { l, l, r, r }, null, null, null, null, new int?[] { 3, 2, 1, 0 }, new[] { 3, 2, 1, 0 } }, // full reverse order
+
+                // multiple orders set
+                { new[] { l, l, l, r, r, r }, new[] { 1 }, new[] { 4 }, new[] { 2, 1, 0 }, new[] { 5, 4, 3 }, null, new[] { 1, 4, 0, 2, 3, 5 } }, // partial library order first, server order ignored
+                { new[] { l, l, l }, new[] { 1 }, null, null, null, new int?[] { 2, 0, 1 }, new[] { 1, 2, 0 } }, // library order first, then orderby
+                { new[] { l, l, l, r, r, r }, new[] { 2, 1, 0 }, new[] { 5, 4, 3 }, new[] { 1, 2, 0 }, new[] { 4, 5, 3 }, new int?[] { 5, 4, 1, 6, 3, 2 }, new[] { 2, 5, 4, 1, 0, 3 } }, // library order wins (with orderby between local/remote)
+            };
+        }
+
+        [Theory]
+        [MemberData(nameof(GetMetadataProvidersOrderData))]
+        public void GetMetadataProviders_ProviderOrder_MatchesExpected(
+            string[] providers,
+            int[]? libraryLocalOrder,
+            int[]? libraryRemoteOrder,
+            int[]? serverLocalOrder,
+            int[]? serverRemoteOrder,
+            int?[]? hasOrderOrder,
+            int[] expectedOrder)
+        {
+            var item = new MetadataTestItem();
+
+            var nameProvider = new Func<int, string>(i => "Provider" + i);
+
+            var providerList = new List<IMetadataProvider<MetadataTestItem>>();
+            for (var i = 0; i < providers.Length; i++)
+            {
+                var order = hasOrderOrder?[i];
+                providerList.Add(MockIMetadataProviderMapper<MetadataTestItem, MetadataTestItemInfo>(providers[i], nameProvider(i), order: order));
+            }
+
+            var libraryOptions = CreateLibraryOptions(
+                item.GetType().Name,
+                localMetadataReaderOrder: libraryLocalOrder?.Select(nameProvider).ToArray(),
+                metadataFetcherOrder: libraryRemoteOrder?.Select(nameProvider).ToArray());
+            var serverConfiguration = CreateServerConfiguration(
+                item.GetType().Name,
+                localMetadataReaderOrder: serverLocalOrder?.Select(nameProvider).ToArray(),
+                metadataFetcherOrder: serverRemoteOrder?.Select(nameProvider).ToArray());
+
+            var baseItemManager = new Mock<IBaseItemManager>(MockBehavior.Strict);
+            baseItemManager.Setup(i => i.IsMetadataFetcherEnabled(item, It.IsAny<TypeOptions>(), It.IsAny<string>()))
+                .Returns(true);
+
+            using var providerManager = GetProviderManager(serverConfiguration: serverConfiguration, baseItemManager: baseItemManager.Object);
+            AddParts(providerManager, metadataProviders: providerList);
+
+            var actualProviders = providerManager.GetMetadataProviders<MetadataTestItem>(item, libraryOptions).ToList();
+
+            Assert.Equal(providerList.Count, actualProviders.Count);
+            var actualOrder = actualProviders.Select(i => providerList.IndexOf(i)).ToArray();
+            Assert.Equal(expectedOrder, actualOrder);
+        }
+
+        [Theory]
+        [InlineData(nameof(IMetadataProvider))]
+        [InlineData(nameof(ILocalMetadataProvider))]
+        [InlineData(nameof(IRemoteMetadataProvider))]
+        [InlineData(nameof(ICustomMetadataProvider))]
+        public void GetMetadataProviders_CanRefreshMetadataBasic_ReturnsTrue(string providerType)
+        {
+            GetMetadataProviders_CanRefreshMetadata_Tester(providerType, true);
+        }
+
+        [Theory]
+        [InlineData(nameof(ILocalMetadataProvider), false, true)]
+        [InlineData(nameof(IRemoteMetadataProvider), false, false)]
+        [InlineData(nameof(ICustomMetadataProvider), false, false)]
+        [InlineData(nameof(ILocalMetadataProvider), true, true)]
+        [InlineData(nameof(ICustomMetadataProvider), true, false)]
+        public void GetMetadataProviders_CanRefreshMetadataLocked_WhenLocalOrForced(string providerType, bool forced, bool expected)
+        {
+            GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, itemLocked: true, providerForced: forced);
+        }
+
+        [Theory]
+        [InlineData(nameof(ILocalMetadataProvider), false, true)]
+        [InlineData(nameof(ICustomMetadataProvider), false, true)]
+        [InlineData(nameof(IRemoteMetadataProvider), false, false)]
+        [InlineData(nameof(IRemoteMetadataProvider), true, true)]
+        public void GetMetadataProviders_CanRefreshMetadataBaseItemEnabled_WhenEnabledOrNotRemote(string providerType, bool baseItemEnabled, bool expected)
+        {
+            GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, baseItemEnabled: baseItemEnabled);
+        }
+
+        [Theory]
+        [InlineData(nameof(IRemoteMetadataProvider), false, true)]
+        [InlineData(nameof(ICustomMetadataProvider), false, true)]
+        [InlineData(nameof(ILocalMetadataProvider), false, false)]
+        [InlineData(nameof(ILocalMetadataProvider), true, true)]
+        public void GetMetadataProviders_CanRefreshMetadataSupportsLocal_WhenSupportsOrNotLocal(string providerType, bool supportsLocalMetadata, bool expected)
+        {
+            GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, supportsLocalMetadata: supportsLocalMetadata);
+        }
+
+        [Theory]
+        [InlineData(nameof(ICustomMetadataProvider), true)]
+        [InlineData(nameof(IRemoteMetadataProvider), true)]
+        [InlineData(nameof(ILocalMetadataProvider), false)]
+        public void GetMetadataProviders_CanRefreshMetadataOwned_WhenNotLocal(string providerType, bool expected)
+        {
+            GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, ownedItem: true);
+        }
+
+        private static void GetMetadataProviders_CanRefreshMetadata_Tester(
+            string providerType,
+            bool expected,
+            bool itemLocked = false,
+            bool baseItemEnabled = true,
+            bool providerForced = false,
+            bool supportsLocalMetadata = true,
+            bool ownedItem = false)
+        {
+            var item = new MetadataTestItem
+            {
+                IsLocked = itemLocked,
+                OwnerId = ownedItem ? Guid.NewGuid() : Guid.Empty,
+                EnableLocalMetadata = supportsLocalMetadata
+            };
+
+            var providerName = "provider";
+            var provider = MockIMetadataProviderMapper<MetadataTestItem, MetadataTestItemInfo>(providerType, providerName, forced: providerForced);
+
+            var baseItemManager = new Mock<IBaseItemManager>(MockBehavior.Strict);
+            baseItemManager.Setup(i => i.IsMetadataFetcherEnabled(item, It.IsAny<TypeOptions>(), providerName))
+                .Returns(baseItemEnabled);
+
+            using var providerManager = GetProviderManager(baseItemManager: baseItemManager.Object);
+            AddParts(providerManager, metadataProviders: new[] { provider });
+
+            var actualProviders = providerManager.GetMetadataProviders<MetadataTestItem>(item, new LibraryOptions()).ToArray();
+
+            Assert.Equal(expected ? 1 : 0, actualProviders.Length);
+        }
+
+        private static Mock<IMetadataService> MockIMetadataService(bool refreshPrimary, bool canRefresh, int order = 0)
+        {
+            var service = new Mock<IMetadataService>(MockBehavior.Strict);
+            service.Setup(s => s.Order)
+                .Returns(order);
+            service.Setup(s => s.CanRefreshPrimary(It.IsAny<Type>()))
+                .Returns(refreshPrimary);
+            service.Setup(s => s.CanRefresh(It.IsAny<BaseItem>()))
+                .Returns(canRefresh);
+            service.Setup(s => s.RefreshMetadata(It.IsAny<BaseItem>(), It.IsAny<MetadataRefreshOptions>(), It.IsAny<CancellationToken>()))
+                .Returns(Task.FromResult(ItemUpdateType.MetadataDownload));
+            return service;
+        }
+
+        private static IImageProvider MockIImageProvider<TProviderType>(string name, BaseItem expectedType, bool supports = true, int? order = null, bool errorOnSupported = false)
+            where TProviderType : class, IImageProvider
+        {
+            Mock<IHasOrder>? hasOrder = null;
+            if (order != null)
+            {
+                hasOrder = new Mock<IHasOrder>(MockBehavior.Strict);
+                hasOrder.Setup(i => i.Order)
+                    .Returns((int)order);
+            }
+
+            var provider = hasOrder == null
+                ? new Mock<TProviderType>(MockBehavior.Strict)
+                : hasOrder.As<TProviderType>();
+            provider.Setup(p => p.Name)
+                .Returns(name);
+            if (errorOnSupported)
+            {
+                provider.Setup(p => p.Supports(It.IsAny<BaseItem>()))
+                    .Throws(new ArgumentException("Provider threw exception on Supports(item)"));
+            }
+            else
+            {
+                provider.Setup(p => p.Supports(expectedType))
+                    .Returns(supports);
+            }
+
+            return provider.Object;
+        }
+
+        private static IMetadataProvider<TItemType> MockIMetadataProviderMapper<TItemType, TLookupInfoType>(string typeName, string providerName, int? order = null, bool forced = false)
+            where TItemType : BaseItem, IHasLookupInfo<TLookupInfoType>
+            where TLookupInfoType : ItemLookupInfo, new()
+            => typeName switch
+            {
+                "ILocalMetadataProvider" => MockIMetadataProvider<ILocalMetadataProvider<TItemType>, TItemType>(providerName, order, forced),
+                "IRemoteMetadataProvider" => MockIMetadataProvider<IRemoteMetadataProvider<TItemType, TLookupInfoType>, TItemType>(providerName, order, forced),
+                "ICustomMetadataProvider" => MockIMetadataProvider<ICustomMetadataProvider<TItemType>, TItemType>(providerName, order, forced),
+                _ => MockIMetadataProvider<IMetadataProvider<TItemType>, TItemType>(providerName, order, forced)
+            };
+
+        private static IMetadataProvider<TItemType> MockIMetadataProvider<TProviderType, TItemType>(string name, int? order = null, bool forced = false)
+            where TProviderType : class, IMetadataProvider<TItemType>
+            where TItemType : BaseItem
+        {
+            Mock<IForcedProvider>? forcedProvider = null;
+            if (forced)
+            {
+                forcedProvider = new Mock<IForcedProvider>();
+            }
+
+            Mock<IHasOrder>? hasOrder = null;
+            if (order != null)
+            {
+                hasOrder = forcedProvider == null ? new Mock<IHasOrder>() : forcedProvider.As<IHasOrder>();
+                hasOrder.Setup(i => i.Order)
+                    .Returns((int)order);
+            }
+
+            var provider = hasOrder == null
+                ? new Mock<TProviderType>(MockBehavior.Strict)
+                : hasOrder.As<TProviderType>();
+            provider.Setup(p => p.Name)
+                .Returns(name);
+
+            return provider.Object;
+        }
+
+        private static LibraryOptions CreateLibraryOptions(
+            string typeName,
+            string[]? imageFetcherOrder = null,
+            string[]? localMetadataReaderOrder = null,
+            string[]? metadataFetcherOrder = null)
+        {
+            var libraryOptions = new LibraryOptions
+            {
+                LocalMetadataReaderOrder = localMetadataReaderOrder
+            };
+
+            // only create type options if populating it with something
+            if (imageFetcherOrder != null || metadataFetcherOrder != null)
+            {
+                imageFetcherOrder ??= Array.Empty<string>();
+                metadataFetcherOrder ??= Array.Empty<string>();
+
+                libraryOptions.TypeOptions = new[]
+                {
+                    new TypeOptions
+                    {
+                        Type = typeName,
+                        ImageFetcherOrder = imageFetcherOrder,
+                        MetadataFetcherOrder = metadataFetcherOrder
+                    }
+                };
+            }
+
+            return libraryOptions;
+        }
+
+        private static ServerConfiguration CreateServerConfiguration(
+            string typeName,
+            string[]? imageFetcherOrder = null,
+            string[]? localMetadataReaderOrder = null,
+            string[]? metadataFetcherOrder = null)
+        {
+            var serverConfiguration = new ServerConfiguration();
+
+            // only create type options if populating it with something
+            if (imageFetcherOrder != null || localMetadataReaderOrder != null || metadataFetcherOrder != null)
+            {
+                imageFetcherOrder ??= Array.Empty<string>();
+                localMetadataReaderOrder ??= Array.Empty<string>();
+                metadataFetcherOrder ??= Array.Empty<string>();
+
+                serverConfiguration.MetadataOptions = new[]
+                {
+                    new MetadataOptions
+                    {
+                        ItemType = typeName,
+                        ImageFetcherOrder = imageFetcherOrder,
+                        LocalMetadataReaderOrder = localMetadataReaderOrder,
+                        MetadataFetcherOrder = metadataFetcherOrder
+                    }
+                };
+            }
+
+            return serverConfiguration;
+        }
+
+        private static ProviderManager GetProviderManager(
+            ServerConfiguration? serverConfiguration = null,
+            LibraryOptions? libraryOptions = null,
+            IBaseItemManager? baseItemManager = null)
+        {
+            var serverConfigurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Strict);
+            serverConfigurationManager.Setup(i => i.Configuration)
+                .Returns(serverConfiguration ?? new ServerConfiguration());
+
+            var libraryManager = new Mock<ILibraryManager>(MockBehavior.Strict);
+            libraryManager.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>()))
+                .Returns(libraryOptions ?? new LibraryOptions());
+
+            var providerManager = new ProviderManager(
+                Mock.Of<IHttpClientFactory>(),
+                Mock.Of<ISubtitleManager>(),
+                serverConfigurationManager.Object,
+                Mock.Of<ILibraryMonitor>(),
+                _logger,
+                Mock.Of<IFileSystem>(),
+                Mock.Of<IServerApplicationPaths>(),
+                libraryManager.Object,
+                baseItemManager!);
+
+            return providerManager;
+        }
+
+        private static void AddParts(
+            ProviderManager providerManager,
+            IEnumerable<IImageProvider>? imageProviders = null,
+            IEnumerable<IMetadataService>? metadataServices = null,
+            IEnumerable<IMetadataProvider>? metadataProviders = null,
+            IEnumerable<IMetadataSaver>? metadataSavers = null,
+            IEnumerable<IExternalId>? externalIds = null)
+        {
+            imageProviders ??= Array.Empty<IImageProvider>();
+            metadataServices ??= Array.Empty<IMetadataService>();
+            metadataProviders ??= Array.Empty<IMetadataProvider>();
+            metadataSavers ??= Array.Empty<IMetadataSaver>();
+            externalIds ??= Array.Empty<IExternalId>();
+
+            providerManager.AddParts(imageProviders, metadataServices, metadataProviders, metadataSavers, externalIds);
+        }
+
+        /// <summary>
+        /// Simple <see cref="BaseItem"/> extension to make SupportsLocalMetadata directly settable.
+        /// </summary>
+        internal class MetadataTestItem : BaseItem, IHasLookupInfo<MetadataTestItemInfo>
+        {
+            public bool EnableLocalMetadata { get; set; } = true;
+
+            public override bool SupportsLocalMetadata => EnableLocalMetadata;
+
+            public MetadataTestItemInfo GetLookupInfo()
+            {
+                return GetItemLookupInfo<MetadataTestItemInfo>();
+            }
+        }
+
+        internal class MetadataTestItemInfo : ItemLookupInfo
+        {
+        }
+    }
+}