aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-08-25 21:00:05 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-08-25 21:06:51 +0200
commit79c37bfcd5c823711bb7675d5ec46f158c1154df (patch)
tree06c8477cee51a80c047e45c4c6ccd651c6d986ab
parent422b2bb3d9193bfe0813d3d654919151eb355d82 (diff)
Build a TMDb series cast from the aggregated credits
-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
-rw-r--r--tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs105
4 files changed, 212 insertions, 33 deletions
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>
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<CastAggregate>
+ {
+ 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<CastAggregate> { 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<CastAggregate> { 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<Cast>
+ {
+ 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()
+ };
+ }
+ }
+}