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 _logger = new NullLogger(); public static TheoryData[], 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[] 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(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(), It.IsAny(), It.IsAny()), 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(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 GetImageProvidersOrderData() => new() { { 3, null, null, null, new[] { 0, 1, 2 } }, // no order options set // library options ordering { 3, Array.Empty(), 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(), 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(i => "Provider" + i); var providerList = new List(); for (var i = 0; i < providerCount; i++) { var order = hasOrderOrder?[i]; providerList.Add(MockIImageProvider(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(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(providerName, item, supports: supports, errorOnSupported: errorOnSupported), "ILocalImageProvider" => MockIImageProvider(providerName, item, supports: supports, errorOnSupported: errorOnSupported), "IRemoteImageProvider" => MockIImageProvider(providerName, item, supports: supports, errorOnSupported: errorOnSupported), "IDynamicImageProvider" => MockIImageProvider(providerName, item, supports: supports, errorOnSupported: errorOnSupported), _ => throw new ArgumentException("Unexpected provider type") }; var refreshOptions = new ImageRefreshOptions(Mock.Of(MockBehavior.Strict)) { ImageRefreshMode = fullRefresh ? MetadataRefreshMode.FullRefresh : MetadataRefreshMode.Default }; var baseItemManager = new Mock(MockBehavior.Strict); baseItemManager.Setup(i => i.IsImageFetcherEnabled(item, It.IsAny(), 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 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(), Array.Empty(), 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(), Array.Empty(), 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(i => "Provider" + i); var providerList = new List>(); for (var i = 0; i < providers.Length; i++) { var order = hasOrderOrder?[i]; providerList.Add(MockIMetadataProviderMapper(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(MockBehavior.Strict); baseItemManager.Setup(i => i.IsMetadataFetcherEnabled(item, It.IsAny(), It.IsAny())) .Returns(true); using var providerManager = GetProviderManager(serverConfiguration: serverConfiguration, baseItemManager: baseItemManager.Object); AddParts(providerManager, metadataProviders: providerList); var actualProviders = providerManager.GetMetadataProviders(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), true)] public void GetMetadataProviders_CanRefreshMetadataOwned(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(providerType, providerName, forced: providerForced); var baseItemManager = new Mock(MockBehavior.Strict); baseItemManager.Setup(i => i.IsMetadataFetcherEnabled(item, It.IsAny(), providerName)) .Returns(baseItemEnabled); using var providerManager = GetProviderManager(baseItemManager: baseItemManager.Object); AddParts(providerManager, metadataProviders: new[] { provider }); var actualProviders = providerManager.GetMetadataProviders(item, new LibraryOptions()).ToArray(); Assert.Equal(expected ? 1 : 0, actualProviders.Length); } private static Mock MockIMetadataService(bool refreshPrimary, bool canRefresh, int order = 0) { var service = new Mock(MockBehavior.Strict); service.Setup(s => s.Order) .Returns(order); service.Setup(s => s.CanRefreshPrimary(It.IsAny())) .Returns(refreshPrimary); service.Setup(s => s.CanRefresh(It.IsAny())) .Returns(canRefresh); service.Setup(s => s.RefreshMetadata(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.FromResult(ItemUpdateType.MetadataDownload)); return service; } private static IImageProvider MockIImageProvider(string name, BaseItem expectedType, bool supports = true, int? order = null, bool errorOnSupported = false) where TProviderType : class, IImageProvider { Mock? hasOrder = null; if (order is not null) { hasOrder = new Mock(MockBehavior.Strict); hasOrder.Setup(i => i.Order) .Returns((int)order); } var provider = hasOrder is null ? new Mock(MockBehavior.Strict) : hasOrder.As(); provider.Setup(p => p.Name) .Returns(name); if (errorOnSupported) { provider.Setup(p => p.Supports(It.IsAny())) .Throws(new ArgumentException("Provider threw exception on Supports(item)")); } else { provider.Setup(p => p.Supports(expectedType)) .Returns(supports); } return provider.Object; } private static IMetadataProvider MockIMetadataProviderMapper(string typeName, string providerName, int? order = null, bool forced = false) where TItemType : BaseItem, IHasLookupInfo where TLookupInfoType : ItemLookupInfo, new() => typeName switch { "ILocalMetadataProvider" => MockIMetadataProvider, TItemType>(providerName, order, forced), "IRemoteMetadataProvider" => MockIMetadataProvider, TItemType>(providerName, order, forced), "ICustomMetadataProvider" => MockIMetadataProvider, TItemType>(providerName, order, forced), _ => MockIMetadataProvider, TItemType>(providerName, order, forced) }; private static IMetadataProvider MockIMetadataProvider(string name, int? order = null, bool forced = false) where TProviderType : class, IMetadataProvider where TItemType : BaseItem { Mock? forcedProvider = null; if (forced) { forcedProvider = new Mock(); } Mock? hasOrder = null; if (order is not null) { hasOrder = forcedProvider is null ? new Mock() : forcedProvider.As(); hasOrder.Setup(i => i.Order) .Returns((int)order); } var provider = hasOrder is null ? new Mock(MockBehavior.Strict) : hasOrder.As(); 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 is not null || metadataFetcherOrder is not null) { imageFetcherOrder ??= Array.Empty(); metadataFetcherOrder ??= Array.Empty(); 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 is not null || localMetadataReaderOrder is not null || metadataFetcherOrder is not null) { imageFetcherOrder ??= Array.Empty(); localMetadataReaderOrder ??= Array.Empty(); metadataFetcherOrder ??= Array.Empty(); 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(MockBehavior.Strict); serverConfigurationManager.Setup(i => i.Configuration) .Returns(serverConfiguration ?? new ServerConfiguration()); var libraryManager = new Mock(MockBehavior.Strict); libraryManager.Setup(i => i.GetLibraryOptions(It.IsAny())) .Returns(libraryOptions ?? new LibraryOptions()); var providerManager = new ProviderManager( Mock.Of(), Mock.Of(), serverConfigurationManager.Object, Mock.Of(), _logger, Mock.Of(), Mock.Of(), libraryManager.Object, baseItemManager!); return providerManager; } private static void AddParts( ProviderManager providerManager, IEnumerable? imageProviders = null, IEnumerable? metadataServices = null, IEnumerable? metadataProviders = null, IEnumerable? metadataSavers = null, IEnumerable? externalIds = null) { imageProviders ??= Array.Empty(); metadataServices ??= Array.Empty(); metadataProviders ??= Array.Empty(); metadataSavers ??= Array.Empty(); externalIds ??= Array.Empty(); providerManager.AddParts(imageProviders, metadataServices, metadataProviders, metadataSavers, externalIds); } /// /// Simple extension to make SupportsLocalMetadata directly settable. /// internal class MetadataTestItem : BaseItem, IHasLookupInfo { public bool EnableLocalMetadata { get; set; } = true; public override bool SupportsLocalMetadata => EnableLocalMetadata; public MetadataTestItemInfo GetLookupInfo() { return GetItemLookupInfo(); } } internal class MetadataTestItemInfo : ItemLookupInfo { } } }