diff options
Diffstat (limited to 'MediaBrowser.Providers')
16 files changed, 363 insertions, 124 deletions
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<Book> { 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<Book> { 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; + +/// <summary> +/// Helpers for comparing the language of fetched metadata with the language that was requested. +/// </summary> +internal static class MetadataLanguageUtils +{ + /// <summary> + /// Gets the language subtag of a language tag, e.g. "es" for "es-ES". + /// </summary> + /// <param name="language">The language tag.</param> + /// <returns>The language subtag, lowercased, or <c>null</c> if none was given.</returns> + 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(); + } + + /// <summary> + /// Determines whether a provider result can be considered to be in the requested language. + /// </summary> + /// <param name="resultLanguage">The language the provider reported for its result, if any.</param> + /// <param name="preferredLanguage">The language that was requested, if any.</param> + /// <returns><c>true</c> if the result is in the requested language or either language is unknown.</returns> + 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 d11db8f531..fe5285bf65 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -209,22 +209,33 @@ namespace MediaBrowser.Providers.Manager } } - if (hasRefreshedMetadata && hasRefreshedImages) + 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<ItemUpdateType> SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, MetadataResult<TItemType> metadataResult, CancellationToken cancellationToken) + async Task<ItemUpdateType> SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, bool refreshStampNeedsSaving, MetadataResult<TItemType> 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) { @@ -939,6 +950,10 @@ namespace MediaBrowser.Providers.Manager private async Task<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, bool replaceData, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken) { var refreshResult = new RefreshResult(); + var preferredLanguage = id?.MetadataLanguage; + + var overviewIsFallback = false; + var taglineIsFallback = false; if (id is not null) { @@ -958,6 +973,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); + } + LogInvalidProviderIds(result, providerName, logName); MergeData(result, temp, [], replaceData, false); 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<MusicAlbum> 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<MusicArtist> 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<Episode> { 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<T> { 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/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index f562d64ddd..d51d913caa 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -27,6 +27,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb /// <summary>Provider for OMDB service.</summary> public class OmdbProvider { + /// <summary>Generational suffixes that OMDb separates from the name with a comma.</summary> + private static readonly string[] NameSuffixes = ["Jr", "Jnr", "Sr", "Snr", "II", "III", "IV", "V"]; + private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly IHttpClientFactory _httpClientFactory; @@ -420,42 +423,96 @@ namespace MediaBrowser.Providers.Plugins.Omdb return; } - if (!string.IsNullOrWhiteSpace(result.Director)) - { - var person = new PersonInfo - { - Name = result.Director.Trim(), - Type = PersonKind.Director - }; + AddPeople(itemResult, result.Director, PersonKind.Director); + AddPeople(itemResult, result.Writer, PersonKind.Writer); + AddPeople(itemResult, result.Actors, PersonKind.Actor); + } - itemResult.AddPerson(person); + /// <summary>Adds the people from a comma separated OMDb credit list.</summary> + /// <typeparam name="T">The item type.</typeparam> + /// <param name="itemResult">The metadata result to add the people to.</param> + /// <param name="credits">The comma separated OMDb credit list.</param> + /// <param name="type">The kind of person each credit describes.</param> + internal static void AddPeople<T>(MetadataResult<T> itemResult, string credits, PersonKind type) + where T : BaseItem + { + if (string.IsNullOrWhiteSpace(credits)) + { + return; } - if (!string.IsNullOrWhiteSpace(result.Writer)) + var names = new List<string>(); + + foreach (var credit in SplitCredits(credits)) { - var person = new PersonInfo + // OMDb annotates the credited role in parentheses, e.g. "Mari Okada (screenplay)". The same + // person can be credited more than once this way, so strip it and let AddPerson deduplicate. + var name = credit; + var annotation = name.IndexOf('(', StringComparison.Ordinal); + if (annotation >= 0) + { + name = name[..annotation]; + } + + name = name.Trim(); + if (name.Length == 0) { - Name = result.Writer.Trim(), - Type = PersonKind.Writer - }; + continue; + } + + // A generational suffix is separated from the name it belongs to by the same comma the list + // uses, e.g. "Jack Salvatore, Jr.", so it has to be joined back instead of becoming a credit. + if (names.Count > 0 && IsNameSuffix(name)) + { + names[^1] = names[^1] + ", " + name; + continue; + } - itemResult.AddPerson(person); + names.Add(name); } - if (!string.IsNullOrWhiteSpace(result.Actors)) + foreach (var name in names) { - var actorList = result.Actors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (var actor in actorList) + itemResult.AddPerson(new PersonInfo { - var person = new PersonInfo - { - Name = actor, - Type = PersonKind.Actor - }; + Name = name, + Type = type + }); + } + } + + // Only the commas between credits, never one inside an annotation: "Jerry Siegel (created by: + // Superman, Superboy)" is one credit, and splitting it blindly invents a person called "Superboy)". + private static IEnumerable<string> SplitCredits(string credits) + { + var depth = 0; + var start = 0; - itemResult.AddPerson(person); + for (var i = 0; i < credits.Length; i++) + { + switch (credits[i]) + { + case '(': + depth++; + break; + case ')': + depth = Math.Max(0, depth - 1); + break; + case ',' when depth == 0: + yield return credits[start..i]; + start = i + 1; + break; } } + + yield return credits[start..]; + } + + private static bool IsNameSuffix(string value) + { + var suffix = value.EndsWith('.') ? value[..^1] : value; + + return NameSuffixes.Contains(suffix, StringComparer.OrdinalIgnoreCase); } private static bool IsConfiguredForEnglish(BaseItem item, string language) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index 11ac477378..0a75b71264 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -114,7 +114,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets } } - var result = new MetadataResult<BoxSet>(); + var result = new MetadataResult<BoxSet> + { + ResultLanguage = language + }; if (tmdbId > 0) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 61294676f7..695f347a9a 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -102,7 +102,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People } } - var result = new MetadataResult<Person>(); + var result = new MetadataResult<Person> + { + 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 06313810a1..9b8803f171 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -40,7 +40,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// <inheritdoc /> public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { - var result = new MetadataResult<Season>(); + var result = new MetadataResult<Season> + { + ResultLanguage = info.MetadataLanguage + }; var config = Plugin.Instance.Configuration; info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? seriesTmdbId); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 9e201f2d7c..6163e20194 100755 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -363,39 +363,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { var config = Plugin.Instance.Configuration; - if (seriesResult.Credits?.Cast is not null) + // The aggregated credits are what hold an actor's several characters apart; the flat ones + // put them in a single string. Only the aggregated list carries the whole run, so prefer it + // and fall back for the rare show TMDb has no aggregation for. + var cast = seriesResult.AggregateCredits?.Cast is { Count: > 0 } aggregated + ? TmdbUtils.MapAggregateCast(aggregated, config, _tmdbClientManager.GetProfileUrl) + : TmdbUtils.MapCast(seriesResult.Credits?.Cast, config, _tmdbClientManager.GetProfileUrl); + + foreach (var actor in cast) { - IEnumerable<Cast> castQuery = seriesResult.Credits.Cast.OrderBy(a => a.Order); - - if (config.HideMissingCastMembers) - { - castQuery = castQuery.Where(a => !string.IsNullOrEmpty(a.ProfilePath)); - } - - foreach (var actor in castQuery.Take(config.MaxCastMembers)) - { - if (string.IsNullOrWhiteSpace(actor.Name)) - { - continue; - } - - var personInfo = new PersonInfo - { - Name = actor.Name.Trim(), - Role = actor.Character?.Trim() ?? string.Empty, - Type = PersonKind.Actor, - SortOrder = actor.Order, - // NOTE: Null values are filtered out above - ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath!) - }; - - if (actor.Id > 0) - { - personInfo.SetProviderId(MetadataProvider.Tmdb, actor.Id.ToString(CultureInfo.InvariantCulture)); - } - - yield return personInfo; - } + yield return actor; } if (seriesResult.Credits?.Crew is not null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index c8e3a7aa52..5379796465 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -137,7 +137,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); - var extraMethods = TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups; + var extraMethods = TvShowMethods.Credits | TvShowMethods.CreditsAggregate | TvShowMethods.Images | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups; if (!(Plugin.Instance?.Configuration.ExcludeTagsSeries).GetValueOrDefault()) { extraMethods |= TvShowMethods.Keywords; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index c83174f97f..44a2f7291e 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -3,10 +3,13 @@ using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Linq; using System.Text.RegularExpressions; using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; using TMDbLib.Objects.General; +using TMDbLib.Objects.TvShows; +using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; namespace MediaBrowser.Providers.Plugins.Tmdb { @@ -130,6 +133,100 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> + /// Maps an aggregated TMDb cast list, whose entries hold every role their member played. + /// </summary> + /// <param name="cast">The aggregated cast list, or <c>null</c>.</param> + /// <param name="config">The configuration deciding how much of the cast to keep.</param> + /// <param name="getProfileUrl">Resolves a profile path into an absolute image url.</param> + /// <returns>One credit per role played.</returns> + internal static IEnumerable<PersonInfo> MapAggregateCast( + IReadOnlyList<CastAggregate>? cast, + PluginConfiguration config, + Func<string?, string?> getProfileUrl) + { + if (cast is null) + { + yield break; + } + + var billed = cast + .Where(member => !string.IsNullOrWhiteSpace(member.Name)) + .Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath)) + .OrderBy(member => member.Order) + .Take(config.MaxCastMembers); + + foreach (var member in billed) + { + // An actor playing several characters over the run gets one aggregated entry holding + // every role, so each of them becomes a credit of its own here. Their own billing puts + // the character they played the longest first. + var characters = member.Roles? + .Where(role => !string.IsNullOrWhiteSpace(role.Character)) + .OrderByDescending(role => role.EpisodeCount) + .Select(role => role.Character!.Trim()) + .ToArray(); + + if (characters is null || characters.Length == 0) + { + characters = [string.Empty]; + } + + foreach (var character in characters) + { + yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, character, getProfileUrl); + } + } + } + + /// <summary> + /// Maps a TMDb cast list whose entries hold the one character their member is credited for. + /// </summary> + /// <param name="cast">The cast list, or <c>null</c>.</param> + /// <param name="config">The configuration deciding how much of the cast to keep.</param> + /// <param name="getProfileUrl">Resolves a profile path into an absolute image url.</param> + /// <returns>One credit per cast entry.</returns> + internal static IEnumerable<PersonInfo> MapCast( + IReadOnlyList<Cast>? cast, + PluginConfiguration config, + Func<string?, string?> getProfileUrl) + { + if (cast is null) + { + yield break; + } + + var billed = cast + .Where(member => !string.IsNullOrWhiteSpace(member.Name)) + .Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath)) + .OrderBy(member => member.Order) + .Take(config.MaxCastMembers); + + foreach (var member in billed) + { + yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, member.Character?.Trim() ?? string.Empty, getProfileUrl); + } + } + + private static PersonInfo CreateCredit(string name, int id, string? profilePath, int? order, string role, Func<string?, string?> getProfileUrl) + { + var personInfo = new PersonInfo + { + Name = name.Trim(), + Role = role, + Type = PersonKind.Actor, + SortOrder = order, + ImageUrl = getProfileUrl(profilePath) + }; + + if (id > 0) + { + personInfo.SetProviderId(MetadataProvider.Tmdb, id.ToString(CultureInfo.InvariantCulture)); + } + + return personInfo; + } + + /// <summary> /// Determines whether a video is a trailer. /// </summary> /// <param name="video">The TMDb video.</param> |
