From 8c4dfc0b710c9314061911eea0daacfd855326e4 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 8 Aug 2026 19:49:26 +0200 Subject: Safeguard against invalid provider ids --- MediaBrowser.Providers/Manager/MetadataService.cs | 42 ++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) (limited to 'MediaBrowser.Providers/Manager/MetadataService.cs') diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 40f2775bd3..fb1781accc 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -858,7 +858,10 @@ namespace MediaBrowser.Providers.Manager { if (refreshResult.UpdateType > ItemUpdateType.None) { - if (!options.RemoveOldMetadata) + // A provider that failed contributed nothing, so the result is not the complete + // replacement the caller asked for. Keeping the existing values stops a provider being + // temporarily unreachable, or choking on a bad id, from deleting the data it owns. + if (!options.RemoveOldMetadata || refreshResult.Failures > 0) { // Add existing metadata to provider result if it does not exist there MergeData(metadata, temp, [], false, false); @@ -932,6 +935,8 @@ namespace MediaBrowser.Providers.Manager { result.Provider = provider.Name; + LogInvalidProviderIds(result.Item, providerName, logName); + MergeData(result, temp, [], replaceData, false); MergeNewData(temp.Item, id); @@ -957,6 +962,29 @@ namespace MediaBrowser.Providers.Manager return refreshResult; } + /// + /// Reports the ids a provider returned that cannot belong to the provider they are filed under. + /// + /// + /// The ids are dropped when merging, this names the provider that produced them so the source of a + /// recurring bad id can be found. + /// + private void LogInvalidProviderIds(TItemType item, string providerName, string logName) + { + if (item?.ProviderIds is null || !Logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + foreach (var (key, value) in item.ProviderIds) + { + if (!ProviderIdsExtensions.IsValidProviderId(key, value)) + { + Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Item}", key, value, providerName, logName); + } + } + } + private void MergeNewData(TItemType source, TIdType lookupInfo) { // Copy new provider id's that may have been obtained @@ -964,6 +992,11 @@ namespace MediaBrowser.Providers.Manager { var key = providerId.Key; + if (!ProviderIdsExtensions.IsValidProviderId(key, providerId.Value)) + { + continue; + } + // Don't replace existing Id's. lookupInfo.ProviderIds.TryAdd(key, providerId.Value); } @@ -1175,6 +1208,13 @@ namespace MediaBrowser.Providers.Manager { var key = id.Key; + // An id that cannot belong to the provider it is filed under only breaks that provider on + // the next refresh, so never let one in - not even when replacing all metadata. + if (!ProviderIdsExtensions.IsValidProviderId(key, id.Value)) + { + continue; + } + // Don't replace existing Id's. if (replaceData) { -- cgit v1.2.3 From 4e9713a03284a88d01d2d114e49d3a90d65aaebc Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 10 Aug 2026 23:10:33 +0200 Subject: Apply review suggestions --- MediaBrowser.Providers/Manager/MetadataService.cs | 28 +++++++++++++++ .../Manager/MetadataServiceRefreshTests.cs | 41 ++++++++++++++++++++++ 2 files changed, 69 insertions(+) (limited to 'MediaBrowser.Providers/Manager/MetadataService.cs') diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index fb1781accc..c6c15198be 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -1137,6 +1137,8 @@ namespace MediaBrowser.Providers.Manager if (!lockedFields.Contains(MetadataField.Cast)) { + RemoveInvalidProviderIds(sourceResult.People); + if (replaceData || targetResult.People is null || targetResult.People.Count == 0) { targetResult.People = sourceResult.People; @@ -1291,6 +1293,32 @@ namespace MediaBrowser.Providers.Manager } } + private static void RemoveInvalidProviderIds(IReadOnlyList people) + { + if (people is null) + { + return; + } + + foreach (var person in people) + { + if (person.ProviderIds is null || person.ProviderIds.Count == 0) + { + continue; + } + + var invalidKeys = person.ProviderIds + .Where(id => !ProviderIdsExtensions.IsValidProviderId(id.Key, id.Value)) + .Select(id => id.Key) + .ToArray(); + + foreach (var key in invalidKeys) + { + person.ProviderIds.Remove(key); + } + } + } + private static void MergePeople(IReadOnlyList source, IReadOnlyList target) { var sourceByName = source.ToLookup(p => p.Name.RemoveDiacritics(), StringComparer.OrdinalIgnoreCase); diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs index 449abb2e6a..cbc8a65577 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs @@ -2,7 +2,9 @@ using System; using System.Collections.Generic; 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; @@ -95,6 +97,45 @@ namespace Jellyfin.Providers.Tests.Manager Assert.Equal("tt0113375", item.GetProviderId(MetadataProvider.Imdb)); } + [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() -- cgit v1.2.3 From a4ff630d1f7881e781f055576d40f3be89c82720 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 12 Aug 2026 08:39:00 +0200 Subject: Fix English metadata blocking localized providers ranked below it --- .../Providers/MetadataResult.cs | 8 +-- .../Books/ComicBookInfo/ComicBookInfoProvider.cs | 2 +- .../Books/ComicInfo/ExternalComicInfoProvider.cs | 2 +- .../Books/ComicInfo/InternalComicInfoProvider.cs | 2 +- .../Manager/MetadataLanguageUtils.cs | 44 +++++++++++++++ MediaBrowser.Providers/Manager/MetadataService.cs | 24 ++++++++ .../Plugins/AudioDb/AudioDbAlbumProvider.cs | 64 ++++++++++++---------- .../Plugins/AudioDb/AudioDbArtistProvider.cs | 64 ++++++++++++---------- .../Plugins/Omdb/OmdbEpisodeProvider.cs | 4 +- .../Plugins/Omdb/OmdbItemProvider.cs | 4 +- .../Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs | 5 +- .../Plugins/Tmdb/People/TmdbPersonProvider.cs | 5 +- .../Plugins/Tmdb/TV/TmdbSeasonProvider.cs | 5 +- .../Manager/MetadataLanguageUtilsTests.cs | 38 +++++++++++++ 14 files changed, 202 insertions(+), 69 deletions(-) create mode 100644 MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs create mode 100644 tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs (limited to 'MediaBrowser.Providers/Manager/MetadataService.cs') diff --git a/MediaBrowser.Controller/Providers/MetadataResult.cs b/MediaBrowser.Controller/Providers/MetadataResult.cs index ef69885fcf..48fc22a0fb 100644 --- a/MediaBrowser.Controller/Providers/MetadataResult.cs +++ b/MediaBrowser.Controller/Providers/MetadataResult.cs @@ -16,11 +16,6 @@ namespace MediaBrowser.Controller.Providers private List<(string Url, ImageType Type)> _remoteImages; private List _people; - public MetadataResult() - { - ResultLanguage = "en"; - } - public List Images { get => _images ??= []; @@ -43,6 +38,9 @@ namespace MediaBrowser.Controller.Providers public T Item { get; set; } + /// + /// Gets or sets the language the fetched metadata is in. + /// public string ResultLanguage { get; set; } public string Provider { get; set; } diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs index 2bd2676ceb..a06de95fce 100644 --- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs @@ -204,7 +204,7 @@ public class ComicBookInfoProvider : IComicProvider { try { - return CultureInfo.GetCultureInfo(language).DisplayName; + return CultureInfo.GetCultureInfo(language).TwoLetterISOLanguageName; } catch (CultureNotFoundException) { diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs index cfd22a850e..e3d1f544cf 100644 --- a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs @@ -52,7 +52,7 @@ public class ExternalComicInfoProvider : IComicProvider var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); - ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName); return metadataResult; } diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs index 19062452b9..4b14837441 100644 --- a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs @@ -50,7 +50,7 @@ public class InternalComicInfoProvider : IComicProvider var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); - ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName); return metadataResult; } diff --git a/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs b/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs new file mode 100644 index 0000000000..92a16feaee --- /dev/null +++ b/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs @@ -0,0 +1,44 @@ +using System; + +namespace MediaBrowser.Providers.Manager; + +/// +/// Helpers for comparing the language of fetched metadata with the language that was requested. +/// +internal static class MetadataLanguageUtils +{ + /// + /// Gets the language subtag of a language tag, e.g. "es" for "es-ES". + /// + /// The language tag. + /// The language subtag, lowercased, or null if none was given. + public static string? GetLanguageSubtag(string? language) + { + if (string.IsNullOrEmpty(language)) + { + return null; + } + + var separator = language.IndexOf('-', StringComparison.Ordinal); + + return (separator == -1 ? language : language[..separator]).ToLowerInvariant(); + } + + /// + /// Determines whether a provider result can be considered to be in the requested language. + /// + /// The language the provider reported for its result, if any. + /// The language that was requested, if any. + /// true if the result is in the requested language or either language is unknown. + public static bool MatchesPreferredLanguage(string? resultLanguage, string? preferredLanguage) + { + // A provider that doesn't report a language cannot be judged, assume it honored the request + if (string.IsNullOrEmpty(resultLanguage) || string.IsNullOrEmpty(preferredLanguage)) + { + return true; + } + + // Compare on the language subtag only so that e.g. "es" matches "es-ES" + return string.Equals(GetLanguageSubtag(resultLanguage), GetLanguageSubtag(preferredLanguage), StringComparison.Ordinal); + } +} diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 40f2775bd3..8788c860bc 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -913,6 +913,10 @@ namespace MediaBrowser.Providers.Manager private async Task ExecuteRemoteProviders(MetadataResult temp, string logName, bool replaceData, TIdType id, IEnumerable> providers, CancellationToken cancellationToken) { var refreshResult = new RefreshResult(); + var preferredLanguage = id?.MetadataLanguage; + + var overviewIsFallback = false; + var taglineIsFallback = false; if (id is not null) { @@ -932,6 +936,26 @@ namespace MediaBrowser.Providers.Manager { result.Provider = provider.Name; + if (MetadataLanguageUtils.MatchesPreferredLanguage(result.ResultLanguage, preferredLanguage)) + { + if (overviewIsFallback && !string.IsNullOrEmpty(result.Item.Overview)) + { + temp.Item.Overview = null; + overviewIsFallback = false; + } + + if (taglineIsFallback && !string.IsNullOrEmpty(result.Item.Tagline)) + { + temp.Item.Tagline = null; + taglineIsFallback = false; + } + } + else + { + overviewIsFallback |= string.IsNullOrEmpty(temp.Item.Overview) && !string.IsNullOrEmpty(result.Item.Overview); + taglineIsFallback |= string.IsNullOrEmpty(temp.Item.Tagline) && !string.IsNullOrEmpty(result.Item.Tagline); + } + MergeData(result, temp, [], replaceData, false); MergeNewData(temp.Item, id); diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs index 0acd44afbe..1903adfbdd 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs @@ -21,6 +21,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Music; namespace MediaBrowser.Providers.Plugins.AudioDb @@ -77,7 +78,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { result.Item = new MusicAlbum(); result.HasMetadata = true; - ProcessResult(result.Item, obj.album[0], info.MetadataLanguage); + ProcessResult(result, obj.album[0], info.MetadataLanguage); } } } @@ -85,8 +86,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb return result; } - private void ProcessResult(MusicAlbum item, Album result, string preferredLanguage) + private void ProcessResult(MetadataResult metadataResult, Album result, string preferredLanguage) { + var item = metadataResult.Item; + if (Plugin.Instance.Configuration.ReplaceAlbumName && !string.IsNullOrWhiteSpace(result.strAlbum)) { item.Album = result.strAlbum; @@ -113,43 +116,48 @@ namespace MediaBrowser.Providers.Plugins.AudioDb item.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, result.strMusicBrainzArtistID); item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, result.strMusicBrainzID); - string overview = null; - - if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionDE; - } - else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionFR; - } - else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionNL; - } - else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionRU; - } - else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionIT; - } - else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionPT; - } + var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage); + var overview = GetDescription(result, language); if (string.IsNullOrWhiteSpace(overview)) { overview = string.IsNullOrWhiteSpace(result.strDescriptionEN) ? result.strDescription : result.strDescriptionEN; + + // The description is not in the requested language, mark it as English so it does not + // block a provider further down the list that can serve the requested language + metadataResult.ResultLanguage = "en"; + } + else + { + metadataResult.ResultLanguage = language; } item.Overview = (overview ?? string.Empty).StripHtml(); } + private static string GetDescription(Album result, string language) + => language switch + { + "de" => result.strDescriptionDE, + "en" => result.strDescriptionEN, + "es" => result.strDescriptionES, + "fr" => result.strDescriptionFR, + "he" => result.strDescriptionIL, + "hu" => result.strDescriptionHU, + "it" => result.strDescriptionIT, + "ja" => result.strDescriptionJP, + "nl" => result.strDescriptionNL, + "no" or "nb" or "nn" => result.strDescriptionNO, + "pl" => result.strDescriptionPL, + "pt" => result.strDescriptionPT, + "ru" => result.strDescriptionRU, + "sv" => result.strDescriptionSE, + "zh" => result.strDescriptionCN, + _ => null + }; + internal async Task EnsureInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken) { var xmlPath = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId); diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index c4f4833857..2d9fe4448f 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -22,6 +22,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Music; namespace MediaBrowser.Providers.Plugins.AudioDb @@ -148,7 +149,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { result.Item = new MusicArtist(); result.HasMetadata = true; - ProcessResult(result.Item, artist, info.MetadataLanguage); + ProcessResult(result, artist, info.MetadataLanguage); } return result; @@ -193,8 +194,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb return null; } - private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage) + private void ProcessResult(MetadataResult metadataResult, Artist result, string preferredLanguage) { + var item = metadataResult.Item; + if (!string.IsNullOrWhiteSpace(result.strWebsite)) { item.HomePageUrl = result.strWebsite; @@ -229,43 +232,48 @@ namespace MediaBrowser.Providers.Plugins.AudioDb item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist); item.SetProviderId(MetadataProvider.MusicBrainzArtist, result.strMusicBrainzID); - string overview = null; - - if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyDE; - } - else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyFR; - } - else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyNL; - } - else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyRU; - } - else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyIT; - } - else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyPT; - } + var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage); + var overview = GetBiography(result, language); if (string.IsNullOrWhiteSpace(overview)) { overview = string.IsNullOrWhiteSpace(result.strBiographyEN) ? result.strBiography : result.strBiographyEN; + + // The biography is not in the requested language, mark it as English so it does not + // block a provider further down the list that can serve the requested language + metadataResult.ResultLanguage = "en"; + } + else + { + metadataResult.ResultLanguage = language; } item.Overview = (overview ?? string.Empty).StripHtml(); } + private static string GetBiography(Artist result, string language) + => language switch + { + "de" => result.strBiographyDE, + "en" => result.strBiographyEN, + "es" => result.strBiographyES, + "fr" => result.strBiographyFR, + "he" => result.strBiographyIL, + "hu" => result.strBiographyHU, + "it" => result.strBiographyIT, + "ja" => result.strBiographyJP, + "nl" => result.strBiographyNL, + "no" or "nb" or "nn" => result.strBiographyNO, + "pl" => result.strBiographyPL, + "pt" => result.strBiographyPT, + "ru" => result.strBiographyRU, + "sv" => result.strBiographySE, + "zh" => result.strBiographyCN, + _ => null + }; + internal async Task EnsureArtistInfo(string musicBrainzId, CancellationToken cancellationToken) { var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs index ccff31ebaa..437a997c11 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs @@ -44,7 +44,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb var result = new MetadataResult { Item = new Episode(), - QueriedById = true + QueriedById = true, + // OMDb is not localized, everything it returns is English + ResultLanguage = "en" }; // Allowing this will dramatically increase scan times diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index e84f1359b7..7b245ea5a7 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -218,7 +218,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb var result = new MetadataResult { Item = new T(), - QueriedById = true + QueriedById = true, + // OMDb is not localized, everything it returns is English + ResultLanguage = "en" }; var imdbId = info.GetProviderId(MetadataProvider.Imdb); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index a7bba2d539..1bc2d3654f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -115,7 +115,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets } } - var result = new MetadataResult(); + var result = new MetadataResult + { + ResultLanguage = language + }; if (tmdbId > 0) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 64ab98b262..e456b4e881 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -101,7 +101,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People } } - var result = new MetadataResult(); + var result = new MetadataResult + { + ResultLanguage = info.MetadataLanguage + }; if (personTmdbId > 0) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 9c41d64253..9a997c80b1 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -41,7 +41,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// public async Task> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { - var result = new MetadataResult(); + var result = new MetadataResult + { + ResultLanguage = info.MetadataLanguage + }; var config = Plugin.Instance.Configuration; info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? seriesTmdbId); diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs new file mode 100644 index 0000000000..d3b0b47465 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs @@ -0,0 +1,38 @@ +using MediaBrowser.Providers.Manager; +using Xunit; + +namespace Jellyfin.Providers.Tests.Manager +{ + public class MetadataLanguageUtilsTests + { + [Theory] + [InlineData("es", "es")] + [InlineData("es-ES", "es")] + [InlineData("pt-BR", "pt")] + [InlineData("ES", "es")] + [InlineData(null, null)] + [InlineData("", null)] + public void GetLanguageSubtag_ReturnsLowercasedSubtag(string? language, string? expected) + { + Assert.Equal(expected, MetadataLanguageUtils.GetLanguageSubtag(language)); + } + + [Theory] + [InlineData("es", "es", true)] + [InlineData("es", "es-ES", true)] + [InlineData("es-MX", "es-ES", true)] + [InlineData("ES", "es", true)] + [InlineData("en", "en", true)] + [InlineData("en", "es-ES", false)] + [InlineData("en", "es", false)] + // An unknown language on either side cannot be judged and is assumed to match + [InlineData(null, "es", true)] + [InlineData("", "es", true)] + [InlineData("en", null, true)] + [InlineData("en", "", true)] + public void MatchesPreferredLanguage_ComparesLanguageSubtag(string? resultLanguage, string? preferredLanguage, bool expected) + { + Assert.Equal(expected, MetadataLanguageUtils.MatchesPreferredLanguage(resultLanguage, preferredLanguage)); + } + } +} -- cgit v1.2.3 From 42c70fba63d72a4113a7b8f2e06293e512cc6914 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 22 Aug 2026 08:50:14 +0200 Subject: Additional fixes Co-Authored-By: Cody Robibero --- Jellyfin.Api/Controllers/ItemLookupController.cs | 3 +- Jellyfin.Api/Controllers/ItemUpdateController.cs | 10 +- .../Entities/ProviderIdsExtensions.cs | 38 +++++--- MediaBrowser.Providers/Manager/MetadataService.cs | 107 +++++++++++++++++---- .../Music/AlbumInfoExtensions.cs | 26 +++-- .../Entities/ProviderIdsExtensionsTests.cs | 60 ++++++++++++ .../Manager/MetadataServiceRefreshTests.cs | 100 ++++++++++++++++++- 7 files changed, 285 insertions(+), 59 deletions(-) (limited to 'MediaBrowser.Providers/Manager/MetadataService.cs') diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index d009f80a96..39ba5ab186 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -13,6 +13,7 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; using Microsoft.AspNetCore.Authorization; @@ -263,7 +264,7 @@ public class ItemLookupController : BaseJellyfinApiController searchResult.ProviderIds); // Since the refresh process won't erase provider Ids, we need to set this explicitly now. - item.ProviderIds = searchResult.ProviderIds; + item.SetProviderIds(searchResult.ProviderIds); await _providerManager.RefreshFullItem( item, new MetadataRefreshOptions(new DirectoryService(_fileSystem)) diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 36c82cf461..65fffc4181 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -428,15 +428,7 @@ public class ItemUpdateController : BaseJellyfinApiController if (request.ProviderIds is not null) { - foreach (var pair in request.ProviderIds.ToList()) - { - if (string.IsNullOrEmpty(pair.Value)) - { - request.ProviderIds.Remove(pair.Key); - } - } - - item.ProviderIds = request.ProviderIds; + item.SetProviderIds(request.ProviderIds); } if (item is Video video) diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs index 27d7a4654b..09eba92d9e 100644 --- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs +++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs @@ -159,8 +159,15 @@ public static partial class ProviderIdsExtensions // When name contains a '=' it can't be deserialized from the database if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value) - || name.Contains('=', StringComparison.Ordinal) - || !IsValidProviderId(name, value)) + || name.Contains('=', StringComparison.Ordinal)) + { + return false; + } + + name = name.Trim(); + value = value.Trim(); + + if (!IsValidProviderId(name, value)) { return false; } @@ -197,7 +204,6 @@ public static partial class ProviderIdsExtensions /// The instance. /// The name, this should not contain a '=' character. /// The value. - /// Due to how deserialization from the database works the name cannot contain '='. public static void SetProviderId(this IHasProviderIds instance, string name, string value) { ArgumentNullException.ThrowIfNull(instance); @@ -210,17 +216,27 @@ public static partial class ProviderIdsExtensions throw new ArgumentException("Provider id name cannot contain '='", nameof(name)); } - // Ensure it exists - instance.ProviderIds ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + instance.TrySetProviderId(name, value); + } - // Match on internal MetadataProvider enum string values before adding arbitrary providers - if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue)) + /// + /// Replaces all provider ids, dropping the ones that cannot belong to the provider they are filed under. + /// + /// The instance. + /// The provider ids to set. + public static void SetProviderIds(this IHasProviderIds instance, IReadOnlyDictionary? providerIds) + { + ArgumentNullException.ThrowIfNull(instance); + + instance.ProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (providerIds is null) { - instance.ProviderIds[enumValue] = value; + return; } - else + + foreach (var (name, value) in providerIds) { - instance.ProviderIds[name] = value; + instance.TrySetProviderId(name, value); } } @@ -259,7 +275,7 @@ public static partial class ProviderIdsExtensions } private static bool IsPositiveNumber(string value) - => long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0; + => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0; private static bool IsGuid(string value) => Guid.TryParse(value, CultureInfo.InvariantCulture, out _); diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index c6c15198be..d11db8f531 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -260,21 +260,40 @@ namespace MediaBrowser.Providers.Manager switch (lookupInfo) { case EpisodeInfo episodeInfo: - episodeInfo.SeriesProviderIds = result.ProviderIds; + episodeInfo.SeriesProviderIds = GetValidProviderIds(result.ProviderIds); episodeInfo.ProviderIds.Clear(); break; case SeasonInfo seasonInfo: - seasonInfo.SeriesProviderIds = result.ProviderIds; + seasonInfo.SeriesProviderIds = GetValidProviderIds(result.ProviderIds); seasonInfo.ProviderIds.Clear(); break; default: - lookupInfo.ProviderIds = result.ProviderIds; + lookupInfo.SetProviderIds(result.ProviderIds); lookupInfo.Name = result.Name; lookupInfo.Year = result.ProductionYear; break; } } + private static Dictionary GetValidProviderIds(IReadOnlyDictionary providerIds) + { + var validProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (providerIds is null) + { + return validProviderIds; + } + + foreach (var (name, value) in providerIds) + { + if (ProviderIdsExtensions.IsValidProviderId(name, value)) + { + validProviderIds[name] = value; + } + } + + return validProviderIds; + } + protected async Task SaveItemAsync(MetadataResult result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken) { await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false); @@ -835,6 +854,7 @@ namespace MediaBrowser.Providers.Manager } } + var hasRemoteMetadata = false; var isLocalLocked = temp.Item.IsLocked; if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly)) { @@ -849,6 +869,7 @@ namespace MediaBrowser.Providers.Manager var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, remoteProviders, cancellationToken).ConfigureAwait(false); + hasRemoteMetadata = remoteResult.UpdateType.HasFlag(ItemUpdateType.MetadataDownload); refreshResult.UpdateType |= remoteResult.UpdateType; refreshResult.ErrorMessage = remoteResult.ErrorMessage; refreshResult.Failures += remoteResult.Failures; @@ -858,10 +879,12 @@ namespace MediaBrowser.Providers.Manager { if (refreshResult.UpdateType > ItemUpdateType.None) { - // A provider that failed contributed nothing, so the result is not the complete - // replacement the caller asked for. Keeping the existing values stops a provider being - // temporarily unreachable, or choking on a bad id, from deleting the data it owns. - if (!options.RemoveOldMetadata || refreshResult.Failures > 0) + // Erasing the old values is only safe when a remote provider returned something to + // replace them with. If every one of them failed there is no replacement, and wiping the + // item would turn a provider being temporarily unreachable into permanent data loss. + // A single failure is not enough: Identify asks for the erasure precisely because the + // previous match was wrong, and an unrelated provider throwing must not undo that. + if (!options.RemoveOldMetadata || (refreshResult.Failures > 0 && !hasRemoteMetadata)) { // Add existing metadata to provider result if it does not exist there MergeData(metadata, temp, [], false, false); @@ -935,7 +958,7 @@ namespace MediaBrowser.Providers.Manager { result.Provider = provider.Name; - LogInvalidProviderIds(result.Item, providerName, logName); + LogInvalidProviderIds(result, providerName, logName); MergeData(result, temp, [], replaceData, false); MergeNewData(temp.Item, id); @@ -969,19 +992,48 @@ namespace MediaBrowser.Providers.Manager /// The ids are dropped when merging, this names the provider that produced them so the source of a /// recurring bad id can be found. /// - private void LogInvalidProviderIds(TItemType item, string providerName, string logName) + private void LogInvalidProviderIds(MetadataResult result, string providerName, string logName) + { + if (!Logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + LogInvalidProviderIds(result.Item?.ProviderIds, providerName, logName, null); + + if (result.People is null) + { + return; + } + + foreach (var person in result.People) + { + LogInvalidProviderIds(person.ProviderIds, providerName, logName, person.Name); + } + } + + private void LogInvalidProviderIds(IReadOnlyDictionary providerIds, string providerName, string logName, string personName) { - if (item?.ProviderIds is null || !Logger.IsEnabled(LogLevel.Debug)) + if (providerIds is null) { return; } - foreach (var (key, value) in item.ProviderIds) + foreach (var (key, value) in providerIds) { - if (!ProviderIdsExtensions.IsValidProviderId(key, value)) + if (ProviderIdsExtensions.IsValidProviderId(key, value)) + { + continue; + } + + if (personName is null) { Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Item}", key, value, providerName, logName); } + else + { + Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Person} of {Item}", key, value, providerName, personName, logName); + } } } @@ -997,8 +1049,13 @@ namespace MediaBrowser.Providers.Manager continue; } - // Don't replace existing Id's. - lookupInfo.ProviderIds.TryAdd(key, providerId.Value); + // Don't replace existing Id's, unless the one already there is unusable - handing that + // one to the providers that have yet to run is what makes them fail. + if (!lookupInfo.ProviderIds.TryGetValue(key, out var existingId) + || !ProviderIdsExtensions.IsValidProviderId(key, existingId)) + { + lookupInfo.ProviderIds[key] = providerId.Value; + } } } @@ -1138,6 +1195,7 @@ namespace MediaBrowser.Providers.Manager if (!lockedFields.Contains(MetadataField.Cast)) { RemoveInvalidProviderIds(sourceResult.People); + RemoveInvalidProviderIds(targetResult.People); if (replaceData || targetResult.People is null || targetResult.People.Count == 0) { @@ -1217,15 +1275,24 @@ namespace MediaBrowser.Providers.Manager continue; } - // Don't replace existing Id's. - if (replaceData) + // Don't replace existing Id's, unless the stored one is unusable - that one is the bad + // match the refresh is meant to repair. + if (replaceData + || !target.ProviderIds.TryGetValue(key, out var existingId) + || !ProviderIdsExtensions.IsValidProviderId(key, existingId)) { target.ProviderIds[key] = id.Value; } - else - { - target.ProviderIds.TryAdd(key, id.Value); - } + } + + // A bad id no provider offered a replacement for still has to go, otherwise the item keeps + // failing the same way on every refresh. + foreach (var key in target.ProviderIds + .Where(id => !ProviderIdsExtensions.IsValidProviderId(id.Key, id.Value)) + .Select(id => id.Key) + .ToArray()) + { + target.ProviderIds.Remove(key); } if (replaceData || !target.CriticRating.HasValue) diff --git a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs index d50e2c6c11..2923dd3290 100644 --- a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs +++ b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs @@ -1,7 +1,5 @@ #pragma warning disable CS1591 -using System; -using System.Globalization; using System.Linq; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -25,11 +23,11 @@ namespace MediaBrowser.Providers.Music public static string? GetReleaseGroupId(this AlbumInfo info) { - var id = MusicBrainzId(info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)); + var id = MusicBrainzId(MetadataProvider.MusicBrainzReleaseGroup, info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup))) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzReleaseGroup, i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -38,11 +36,11 @@ namespace MediaBrowser.Providers.Music public static string? GetReleaseId(this AlbumInfo info) { - var id = MusicBrainzId(info.GetProviderId(MetadataProvider.MusicBrainzAlbum)); + var id = MusicBrainzId(MetadataProvider.MusicBrainzAlbum, info.GetProviderId(MetadataProvider.MusicBrainzAlbum)); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbum))) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbum, i.GetProviderId(MetadataProvider.MusicBrainzAlbum))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -52,17 +50,17 @@ namespace MediaBrowser.Providers.Music public static string? GetMusicBrainzArtistId(this AlbumInfo info) { info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzAlbumArtist.ToString(), out string? id); - id = MusicBrainzId(id); + id = MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, id); if (string.IsNullOrEmpty(id)) { info.ArtistProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out id); - id = MusicBrainzId(id); + id = MusicBrainzId(MetadataProvider.MusicBrainzArtist, id); } if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -72,11 +70,11 @@ namespace MediaBrowser.Providers.Music public static string? GetMusicBrainzArtistId(this ArtistInfo info) { info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out var id); - id = MusicBrainzId(id); + id = MusicBrainzId(MetadataProvider.MusicBrainzArtist, id); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -84,9 +82,9 @@ namespace MediaBrowser.Providers.Music } /// - /// Returns the id if it can be a MusicBrainz id, otherwise null. + /// Returns the id if it can be an id of the given provider, otherwise null. /// - private static string? MusicBrainzId(string? id) - => Guid.TryParse(id, CultureInfo.InvariantCulture, out _) ? id : null; + private static string? MusicBrainzId(MetadataProvider provider, string? id) + => ProviderIdsExtensions.IsValidProviderId(provider.ToString(), id) ? id : null; } } diff --git a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs index 0fae58fe67..2347c08961 100644 --- a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs +++ b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs @@ -229,6 +229,66 @@ namespace Jellyfin.Model.Tests.Entities Assert.Equal("11", provider.GetProviderId(MetadataProvider.Tmdb)); } + [Theory] + [InlineData(nameof(MetadataProvider.Imdb), " tt0113375 ")] + [InlineData(" Imdb", ExampleImdbId)] + public void TrySetProviderId_SurroundingWhitespace_Trimmed(string name, string value) + { + var provider = new ProviderIdsExtensionsTestsObject(); + + Assert.True(provider.TrySetProviderId(name, value)); + Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void SetProviderIds_ReplacesAll() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Tvdb.ToString()] = "12345"; + + provider.SetProviderIds(new Dictionary + { + [MetadataProvider.Imdb.ToString()] = ExampleImdbId + }); + + Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb)); + Assert.False(provider.HasProviderId(MetadataProvider.Tvdb)); + } + + [Fact] + public void SetProviderIds_ForeignId_Dropped() + { + var provider = new ProviderIdsExtensionsTestsObject(); + + provider.SetProviderIds(new Dictionary + { + [MetadataProvider.Tmdb.ToString()] = "nm0000123", + [MetadataProvider.Imdb.ToString()] = ExampleImdbId, + [MetadataProvider.Tvdb.ToString()] = string.Empty + }); + + Assert.False(provider.HasProviderId(MetadataProvider.Tmdb)); + Assert.False(provider.HasProviderId(MetadataProvider.Tvdb)); + Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void SetProviderIds_Null_Clears() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId; + + provider.SetProviderIds(null); + + Assert.Empty(provider.ProviderIds); + } + + [Fact] + public void SetProviderIds_NullInstance_ThrowsArgumentNullException() + { + Assert.Throws(() => ProviderIdsExtensions.SetProviderIds(null!, new Dictionary())); + } + [Fact] public void RemoveProviderId_Null_Remove() { diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs index cbc8a65577..1d2fb2e760 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -22,9 +23,13 @@ namespace Jellyfin.Providers.Tests.Manager public class MetadataServiceRefreshTests { [Theory] - [InlineData(false, "existing overview")] - [InlineData(true, null)] - public async Task RefreshWithProviders_ReplaceAllMetadata_KeepsExistingDataOnProviderFailure(bool allProvidersSucceed, string? expectedOverview) + // 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 { @@ -63,7 +68,51 @@ namespace Jellyfin.Providers.Tests.Manager Assert.Equal(allProvidersSucceed ? 0 : 1, result.Failures); Assert.Equal("new tagline", item.Tagline); - Assert.Equal(expectedOverview, item.Overview); + 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] @@ -97,6 +146,49 @@ namespace Jellyfin.Providers.Tests.Manager 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)] -- cgit v1.2.3 From 090b610eb131eb416ce3e4527dc9b568bf130c14 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 23 Aug 2026 09:27:26 +0200 Subject: Fix person metadata not being fetched on demand or by the people task --- Jellyfin.Api/Controllers/UserLibraryController.cs | 37 +++++++++++++++-------- MediaBrowser.Providers/Manager/MetadataService.cs | 5 ++- 2 files changed, 28 insertions(+), 14 deletions(-) (limited to 'MediaBrowser.Providers/Manager/MetadataService.cs') diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index ea134a4619..da03032249 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -34,6 +34,8 @@ namespace Jellyfin.Api.Controllers; [Tags("Library")] public class UserLibraryController : BaseJellyfinApiController { + private static readonly TimeSpan RefreshOnDemandTimeout = TimeSpan.FromSeconds(3); + private readonly IUserManager _userManager; private readonly IUserDataManager _userDataRepository; private readonly ILibraryManager _libraryManager; @@ -79,7 +81,7 @@ public class UserLibraryController : BaseJellyfinApiController /// An containing the item. [HttpGet("Items/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetItem( + public async Task> GetItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { @@ -98,7 +100,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - QueueRefreshOnDemandIfNeeded(item); + await RefreshOnDemandIfNeeded(item).ConfigureAwait(false); var dtoOptions = new DtoOptions(); @@ -116,7 +118,7 @@ public class UserLibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] - public ActionResult GetItemLegacy( + public Task> GetItemLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => GetItem(userId, itemId); @@ -643,7 +645,7 @@ public class UserLibraryController : BaseJellyfinApiController limit, groupItems); - private void QueueRefreshOnDemandIfNeeded(BaseItem item) + private async Task RefreshOnDemandIfNeeded(BaseItem item) { if (item is not Person) { @@ -656,15 +658,24 @@ public class UserLibraryController : BaseJellyfinApiController return; } - _providerManager.QueueRefresh( - item.Id, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ForceSave = true - }, - RefreshPriority.High); + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ForceSave = true + }; + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted); + timeout.CancelAfter(RefreshOnDemandTimeout); + + try + { + await item.RefreshMetadata(options, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested) + { + _providerManager.QueueRefresh(item.Id, options, RefreshPriority.High); + } } /// diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index d11db8f531..1b43bc23fa 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -209,7 +209,10 @@ namespace MediaBrowser.Providers.Manager } } - if (hasRefreshedMetadata && hasRefreshedImages) + var attemptedFetch = refreshOptions.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly + || refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly; + + if (hasRefreshedMetadata && hasRefreshedImages && attemptedFetch) { item.DateLastRefreshed = DateTime.UtcNow; updateType |= item.OnMetadataChanged(); -- cgit v1.2.3 From 9cc47c4fd6c289118d2c4df0d4866faff083cba2 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 24 Aug 2026 21:52:35 +0200 Subject: Persist the refresh stamp so the people task stops redoing its work --- .../ScheduledTasks/Tasks/PeopleValidationTask.cs | 59 +++++------ MediaBrowser.Providers/Manager/MetadataService.cs | 14 ++- .../Manager/MetadataServiceRefreshTests.cs | 112 +++++++++++++++++++++ 3 files changed, 150 insertions(+), 35 deletions(-) (limited to 'MediaBrowser.Providers/Manager/MetadataService.cs') diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index bd73f63aa7..afb27ddf9e 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -177,56 +177,51 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + List peopleIds; + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - const int PartitionSize = 100; - - var numPeople = await context.BaseItems + // Read the candidates in one go rather than paging them. A refresh stamps the person and takes + // it out of this set, so a growing offset over a shrinking set walks past people it never visits. + peopleIds = await context.BaseItems .AsNoTracking() .Where(b => b.Type == personTypeName) .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) .Where(b => !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || string.IsNullOrEmpty(b.Overview)) - .CountAsync(cancellationToken) + .OrderBy(b => b.Id) + .Select(b => b.Id) + .ToListAsync(cancellationToken) .ConfigureAwait(false); + } - _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople); + _logger.LogDebug("Found {Count} people needing image/overview refresh", peopleIds.Count); - if (numPeople == 0) - { - progress.Report(100); - return; - } + if (peopleIds.Count == 0) + { + progress.Report(100); + return; + } - var numComplete = 0; - var numRefreshed = 0; + var numComplete = 0; + var numRefreshed = 0; - await foreach (var entry in context.BaseItems - .AsNoTracking() - .Where(b => b.Type == personTypeName) - .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) - .Where(b => - !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || - string.IsNullOrEmpty(b.Overview)) - .OrderBy(b => b.Id) - .WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition)) - .PartitionEagerAsync(PartitionSize, cancellationToken) - .WithCancellation(cancellationToken) - .ConfigureAwait(false)) - { - if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false)) - { - numRefreshed++; - } + foreach (var personId in peopleIds) + { + cancellationToken.ThrowIfCancellationRequested(); - numComplete++; - progress.Report(100.0 * numComplete / numPeople); + if (await RefreshPersonAsync(personId, cancellationToken).ConfigureAwait(false)) + { + numRefreshed++; } - _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); + numComplete++; + progress.Report(100.0 * numComplete / peopleIds.Count); } + + _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); } private async Task RefreshPersonAsync(Guid personId, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 26dc8f9930..fe5285bf65 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -212,22 +212,30 @@ namespace MediaBrowser.Providers.Manager var attemptedFetch = refreshOptions.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly || refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly; + var refreshStampNeedsSaving = false; + if (hasRefreshedMetadata && hasRefreshedImages && attemptedFetch) { item.DateLastRefreshed = DateTime.UtcNow; updateType |= item.OnMetadataChanged(); + + // A full refresh queries every provider whether or not anything looks stale. When they all + // come back empty the stamp is the only thing that changed, and without it nothing records + // that the lookup happened, so the next pass repeats the same fruitless queries forever. + refreshStampNeedsSaving = refreshOptions.MetadataRefreshMode == MetadataRefreshMode.FullRefresh + || refreshOptions.ImageRefreshMode == MetadataRefreshMode.FullRefresh; } - updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false); + updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, refreshStampNeedsSaving, metadataResult, cancellationToken).ConfigureAwait(false); await AfterMetadataRefresh(itemOfType, refreshOptions, cancellationToken).ConfigureAwait(false); return updateType; - async Task SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, MetadataResult metadataResult, CancellationToken cancellationToken) + async Task SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, bool refreshStampNeedsSaving, MetadataResult metadataResult, CancellationToken cancellationToken) { // Save if changes were made, or it's never been saved before - if (refreshOptions.ForceSave || updateType > ItemUpdateType.None || isFirstRefresh || refreshOptions.ReplaceAllMetadata || requiresRefresh) + if (refreshOptions.ForceSave || updateType > ItemUpdateType.None || isFirstRefresh || refreshOptions.ReplaceAllMetadata || requiresRefresh || refreshStampNeedsSaving) { if (item.IsFileProtocol) { diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs index 1d2fb2e760..465a032328 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -11,8 +12,10 @@ using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -228,6 +231,100 @@ namespace Jellyfin.Providers.Tests.Manager Assert.Equal("nm0000123", mergedPerson.GetProviderId(MetadataProvider.Imdb)); } + [Theory] + [InlineData(MetadataRefreshMode.FullRefresh, true)] + [InlineData(MetadataRefreshMode.Default, false)] + public async Task RefreshMetadata_ProvidersFoundNothing_PersistsRefreshDateOnFullRefresh(MetadataRefreshMode mode, bool expectSaved) + { + var peoplePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "people"); + + var item = new Person + { + Id = Guid.NewGuid(), + Name = "Test Person", + Path = System.IO.Path.Combine(peoplePath, "T", "Test Person"), + PreferredMetadataLanguage = "en", + PreferredMetadataCountryCode = "US", + DateLastRefreshed = DateTime.UtcNow.AddDays(-60), + DateLastSaved = DateTime.UtcNow.AddDays(-60) + }; + item.PresentationUniqueKey = item.CreatePresentationUniqueKey(); + + var stampBefore = item.DateLastRefreshed; + + var provider = new Mock>(MockBehavior.Loose); + provider.Setup(p => p.Name).Returns("Provider"); + provider.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MetadataResult { HasMetadata = false }); + + var libraryOptions = new LibraryOptions(); + + var libraryManager = new Mock(MockBehavior.Loose); + libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny())).Returns(libraryOptions); + + var providerManager = new Mock(MockBehavior.Loose); + providerManager.Setup(p => p.GetImageProviders(It.IsAny(), It.IsAny())) + .Returns(Array.Empty()); + providerManager.Setup(p => p.GetMetadataProviders(It.IsAny(), It.IsAny())) + .Returns(new[] { (IMetadataProvider)provider.Object }); + providerManager.Setup(p => p.GetMetadataSavers(It.IsAny(), It.IsAny())) + .Returns(Array.Empty()); + + var itemRepository = new Mock(MockBehavior.Loose); + itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny())).ReturnsAsync(true); + + var applicationPaths = new Mock(MockBehavior.Loose); + applicationPaths.Setup(a => a.PeoplePath).Returns(peoplePath); + var configurationManager = new Mock(MockBehavior.Loose); + configurationManager.Setup(c => c.ApplicationPaths).Returns(applicationPaths.Object); + configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + + var fileSystem = new Mock(MockBehavior.Loose); + fileSystem.Setup(f => f.GetFileSystemInfo(It.IsAny())).Returns(new FileSystemMetadata { Exists = false }); + fileSystem.Setup(f => f.GetValidFilename(It.IsAny())).Returns(name => name); + + var mediaSourceManager = new Mock(MockBehavior.Loose); + mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsAny())).Returns(MediaProtocol.File); + + var previousLibraryManager = BaseItem.LibraryManager; + var previousConfigurationManager = BaseItem.ConfigurationManager; + var previousFileSystem = BaseItem.FileSystem; + var previousMediaSourceManager = BaseItem.MediaSourceManager; + BaseItem.LibraryManager = libraryManager.Object; + BaseItem.ConfigurationManager = configurationManager.Object; + BaseItem.FileSystem = fileSystem.Object; + BaseItem.MediaSourceManager = mediaSourceManager.Object; + try + { + var service = new TestPersonMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object, fileSystem.Object); + + await service.RefreshMetadata( + item, + new MetadataRefreshOptions(Mock.Of()) + { + MetadataRefreshMode = mode, + ImageRefreshMode = mode + }, + CancellationToken.None).ConfigureAwait(true); + } + finally + { + BaseItem.LibraryManager = previousLibraryManager; + BaseItem.ConfigurationManager = previousConfigurationManager; + BaseItem.FileSystem = previousFileSystem; + BaseItem.MediaSourceManager = previousMediaSourceManager; + } + + libraryManager.Verify( + l => l.UpdateItemAsync(item, It.IsAny(), It.IsAny(), It.IsAny()), + expectSaved ? Times.Once() : Times.Never()); + + if (expectSaved) + { + Assert.True(item.DateLastRefreshed > stampBefore); + } + } + private sealed class TestMetadataService : MetadataService { public TestMetadataService() @@ -249,5 +346,20 @@ namespace Jellyfin.Providers.Tests.Manager ICollection providers) => RefreshWithProviders(metadata, id, options, providers, ImageProvider, false, CancellationToken.None); } + + private sealed class TestPersonMetadataService : MetadataService + { + public TestPersonMetadataService(ILibraryManager libraryManager, IProviderManager providerManager, IItemRepository itemRepository, IFileSystem fileSystem) + : base( + Mock.Of(), + NullLogger>.Instance, + providerManager, + fileSystem, + libraryManager, + Mock.Of(), + itemRepository) + { + } + } } } -- cgit v1.2.3