aboutsummaryrefslogtreecommitdiff
path: root/MediaBrowser.Providers
diff options
context:
space:
mode:
Diffstat (limited to 'MediaBrowser.Providers')
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs2
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs2
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs2
-rw-r--r--MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs44
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs43
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs64
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs64
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs4
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs4
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs103
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs5
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs5
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs5
-rwxr-xr-xMediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs41
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs97
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>