From 79c37bfcd5c823711bb7675d5ec46f158c1154df Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 25 Aug 2026 21:00:05 +0200 Subject: Build a TMDb series cast from the aggregated credits --- .../Plugins/Tmdb/TV/TmdbSeriesProvider.cs | 41 ++------ .../Plugins/Tmdb/TmdbClientManager.cs | 2 +- MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs | 97 +++++++++++++++++++ .../Tmdb/TmdbUtilsCastTests.cs | 105 +++++++++++++++++++++ 4 files changed, 212 insertions(+), 33 deletions(-) create mode 100644 tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs 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 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 { @@ -129,6 +132,100 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return PersonKind.Unknown; } + /// + /// Maps an aggregated TMDb cast list, whose entries hold every role their member played. + /// + /// The aggregated cast list, or null. + /// The configuration deciding how much of the cast to keep. + /// Resolves a profile path into an absolute image url. + /// One credit per role played. + internal static IEnumerable MapAggregateCast( + IReadOnlyList? cast, + PluginConfiguration config, + Func 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); + } + } + } + + /// + /// Maps a TMDb cast list whose entries hold the one character their member is credited for. + /// + /// The cast list, or null. + /// The configuration deciding how much of the cast to keep. + /// Resolves a profile path into an absolute image url. + /// One credit per cast entry. + internal static IEnumerable MapCast( + IReadOnlyList? cast, + PluginConfiguration config, + Func 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 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; + } + /// /// Determines whether a video is a trailer. /// diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs new file mode 100644 index 0000000000..182e7c52eb --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.Tmdb; +using TMDbLib.Objects.TvShows; +using Xunit; + +namespace Jellyfin.Providers.Tests.Tmdb +{ + public class TmdbUtilsCastTests + { + private static readonly PluginConfiguration _config = new() { MaxCastMembers = 10 }; + + [Fact] + public void MapAggregateCast_MemberWithSeveralRoles_YieldsOneCreditPerRole() + { + var cast = new List + { + CreateAggregate("Megumi Toyoguchi", 1, 0, ("Tabby (voice)", 3), ("Mimiru (voice)", 12)) + }; + + var people = TmdbUtils.MapAggregateCast(cast, _config, _ => null).ToArray(); + + // The character they played the longest comes first, which is their own billing. + Assert.Equal(["Mimiru (voice)", "Tabby (voice)"], people.Select(p => p.Role)); + Assert.All(people, p => Assert.Equal("Megumi Toyoguchi", p.Name)); + Assert.All(people, p => Assert.Equal(PersonKind.Actor, p.Type)); + Assert.All(people, p => Assert.Equal("1", p.GetProviderId(MetadataProvider.Tmdb))); + } + + [Fact] + public void MapAggregateCast_MemberWithoutARole_IsStillCredited() + { + var cast = new List { CreateAggregate("Uncredited Actor", 2, 0) }; + + var person = Assert.Single(TmdbUtils.MapAggregateCast(cast, _config, _ => null)); + + Assert.Equal(string.Empty, person.Role); + } + + [Fact] + public void MapAggregateCast_MoreThanConfigured_KeepsTheTopBilled() + { + var cast = Enumerable.Range(0, 5) + .Select(i => CreateAggregate($"Actor {4 - i}", i + 1, 4 - i, ($"Role {4 - i}", 1))) + .ToList(); + + var people = TmdbUtils.MapAggregateCast(cast, new PluginConfiguration { MaxCastMembers = 2 }, _ => null); + + Assert.Equal(["Actor 0", "Actor 1"], people.Select(p => p.Name)); + } + + [Fact] + public void MapAggregateCast_HideMissingCastMembers_DropsTheOnesWithoutAProfile() + { + var withProfile = CreateAggregate("Has Profile", 1, 0, ("Hero", 1)); + withProfile.ProfilePath = "/profile.jpg"; + var cast = new List { withProfile, CreateAggregate("No Profile", 2, 1, ("Villain", 1)) }; + + var people = TmdbUtils.MapAggregateCast( + cast, + new PluginConfiguration { MaxCastMembers = 10, HideMissingCastMembers = true }, + _ => null); + + Assert.Equal(["Has Profile"], people.Select(p => p.Name)); + } + + [Fact] + public void MapCast_FlatCredits_YieldOneCreditEach() + { + var cast = new List + { + new() { Name = "Kevin Conroy", Id = 1, Order = 0, Character = " Batman (voice) " }, + new() { Name = " ", Id = 2, Order = 1, Character = "Nobody" } + }; + + var person = Assert.Single(TmdbUtils.MapCast(cast, _config, _ => null)); + + Assert.Equal("Kevin Conroy", person.Name); + Assert.Equal("Batman (voice)", person.Role); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MapCast_NoCast_YieldsNothing(bool aggregate) + { + Assert.Empty(aggregate + ? TmdbUtils.MapAggregateCast(null, _config, _ => null) + : TmdbUtils.MapCast(null, _config, _ => null)); + } + + private static CastAggregate CreateAggregate(string name, int id, int order, params (string Character, int Episodes)[] roles) + { + return new CastAggregate + { + Name = name, + Id = id, + Order = order, + Roles = roles.Select(role => new CastRole { Character = role.Character, EpisodeCount = role.Episodes }).ToList() + }; + } + } +} -- cgit v1.2.3