using System; using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace Jellyfin.Providers.Tests.Manager { public class MetadataServiceRefreshTests { [Theory] // RemoveOldMetadata is only ever set by an explicit user action - a refresh with "replace all // metadata", or Identify. A provider failing must not silently downgrade that to a merge: the // providers that did answer supplied the replacement, and the old values are the wrong match // the user asked to get rid of. [InlineData(false)] [InlineData(true)] public async Task RefreshWithProviders_ReplaceAllMetadata_ErasesOldDataWhenAProviderAnswers(bool allProvidersSucceed) { var item = new Movie { Name = "Test Movie", Overview = "existing overview" }; // The provider owning the overview fails, so it contributes nothing to the replacement. var failing = new Mock>(MockBehavior.Loose); failing.Setup(p => p.Name).Returns("Failing"); failing.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) .Returns(allProvidersSucceed ? Task.FromResult(new MetadataResult { HasMetadata = true, Item = new Movie() }) : Task.FromException>(new FormatException("bad id"))); var succeeding = new Mock>(MockBehavior.Loose); succeeding.Setup(p => p.Name).Returns("Succeeding"); succeeding.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) .ReturnsAsync(new MetadataResult { HasMetadata = true, Item = new Movie { Name = "Test Movie", Tagline = "new tagline" } }); var service = new TestMetadataService(); var result = await service.RefreshWithProvidersInternal( new MetadataResult { Item = item }, new MovieInfo { Name = item.Name }, new MetadataRefreshOptions(Mock.Of()) { MetadataRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceAllMetadata = true, RemoveOldMetadata = true }, [failing.Object, succeeding.Object]).ConfigureAwait(true); Assert.Equal(allProvidersSucceed ? 0 : 1, result.Failures); Assert.Equal("new tagline", item.Tagline); Assert.Null(item.Overview); } [Fact] public async Task RefreshWithProviders_ReplaceAllMetadata_KeepsExistingDataWhenEveryRemoteProviderFails() { var item = new Movie { Name = "Test Movie", Overview = "existing overview" }; // Something has to contribute for the merge to run at all, otherwise the item is never touched // and the case is moot. The local provider is the replacement the remote ones did not deliver. var local = new Mock>(MockBehavior.Loose); local.Setup(p => p.Name).Returns("Local"); local.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new MetadataResult { HasMetadata = true, Item = new Movie { Name = "Test Movie", Tagline = "new tagline" } }); var remote = new Mock>(MockBehavior.Loose); remote.Setup(p => p.Name).Returns("Failing"); remote.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) .Returns(Task.FromException>(new HttpRequestException("unreachable"))); var service = new TestMetadataService(); var result = await service.RefreshWithProvidersInternal( new MetadataResult { Item = item }, new MovieInfo { Name = item.Name }, new MetadataRefreshOptions(Mock.Of()) { MetadataRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceAllMetadata = true, RemoveOldMetadata = true }, [local.Object, remote.Object]).ConfigureAwait(true); Assert.Equal(1, result.Failures); Assert.Equal("new tagline", item.Tagline); // No remote provider answered, so erasing the overview would lose it for good. Assert.Equal("existing overview", item.Overview); } [Fact] public async Task RefreshWithProviders_ForeignProviderId_NotStored() { var item = new Movie { Name = "Test Movie" }; var provider = new Mock>(MockBehavior.Loose); provider.Setup(p => p.Name).Returns("Provider"); provider.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) .ReturnsAsync(() => { var found = new Movie { Name = "Test Movie" }; found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123"; found.ProviderIds[MetadataProvider.Imdb.ToString()] = "tt0113375"; return new MetadataResult { HasMetadata = true, Item = found }; }); var service = new TestMetadataService(); await service.RefreshWithProvidersInternal( new MetadataResult { Item = item }, new MovieInfo { Name = item.Name }, new MetadataRefreshOptions(Mock.Of()) { MetadataRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceAllMetadata = true }, [provider.Object]).ConfigureAwait(true); Assert.False(item.HasProviderId(MetadataProvider.Tmdb)); Assert.Equal("tt0113375", item.GetProviderId(MetadataProvider.Imdb)); } [Fact] public async Task RefreshWithProviders_ForeignProviderId_ReplacedInLookupInfo() { var item = new Movie { Name = "Test Movie" }; var lookupInfo = new MovieInfo { Name = item.Name }; lookupInfo.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123"; var answering = new Mock>(MockBehavior.Loose); answering.Setup(p => p.Name).Returns("Answering"); answering.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) .ReturnsAsync(() => { var found = new Movie { Name = "Test Movie" }; found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "12345"; return new MetadataResult { HasMetadata = true, Item = found }; }); string? tmdbIdSeenBySecondProvider = null; var following = new Mock>(MockBehavior.Loose); following.Setup(p => p.Name).Returns("Following"); following.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) .ReturnsAsync((MovieInfo info, CancellationToken _) => { tmdbIdSeenBySecondProvider = info.GetProviderId(MetadataProvider.Tmdb); return new MetadataResult { HasMetadata = false }; }); var service = new TestMetadataService(); await service.RefreshWithProvidersInternal( new MetadataResult { Item = item }, lookupInfo, new MetadataRefreshOptions(Mock.Of()) { MetadataRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceAllMetadata = true }, [answering.Object, following.Object]).ConfigureAwait(true); // The stored id cannot be a TMDb one, so the provider that still has to run must get the id // that was just found instead of failing on the same bad one. Assert.Equal("12345", tmdbIdSeenBySecondProvider); } [Theory] [InlineData(true)] [InlineData(false)] public async Task RefreshWithProviders_ForeignPersonProviderId_NotStored(bool replaceAllMetadata) { var item = new Movie { Name = "Test Movie" }; var existing = new MetadataResult { Item = item }; existing.AddPerson(new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor }); var provider = new Mock>(MockBehavior.Loose); provider.Setup(p => p.Name).Returns("Provider"); provider.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) .ReturnsAsync(() => { var person = new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor }; person.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123"; person.ProviderIds[MetadataProvider.Imdb.ToString()] = "nm0000123"; var found = new MetadataResult { HasMetadata = true, Item = new Movie { Name = "Test Movie" } }; found.AddPerson(person); return found; }); var service = new TestMetadataService(); await service.RefreshWithProvidersInternal( existing, new MovieInfo { Name = item.Name }, new MetadataRefreshOptions(Mock.Of()) { MetadataRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceAllMetadata = replaceAllMetadata }, [provider.Object]).ConfigureAwait(true); var mergedPerson = Assert.Single(existing.People); Assert.False(mergedPerson.HasProviderId(MetadataProvider.Tmdb)); Assert.Equal("nm0000123", mergedPerson.GetProviderId(MetadataProvider.Imdb)); } private sealed class TestMetadataService : MetadataService { public TestMetadataService() : base( Mock.Of(), NullLogger>.Instance, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()) { } public Task RefreshWithProvidersInternal( MetadataResult metadata, MovieInfo id, MetadataRefreshOptions options, ICollection providers) => RefreshWithProviders(metadata, id, options, providers, ImageProvider, false, CancellationToken.None); } } }