fix(tmdb): series cast was the latest season only, not the show (#1242)

* fix(tmdb): series cast was the latest season only, not the show

TMDB documents a TV id's `credits` as the credits of the LATEST SEASON.
We requested exactly that and rendered it as "the cast", so every
long-running show lost every regular who had left: The Boys showed
whoever appears in the newest season, not the ensemble.

The TV details request now also appends `aggregate_credits`, which spans
the whole run — but per TMDB omits the newest season, so neither payload
alone is the cast. `unifiedTvCast` unions them: whole-run billing order
first, then people who appear only in the newest season, deduplicated by
person id. Characters come from the aggregate `roles[]` shape.

Deliberately NO cache-key bump. Rows cached before this simply lack
`aggregate_credits` and keep the previous behaviour until they expire,
which avoids invalidating every user's details cache twice — the roadmap
schedules one consolidated bump once the remaining append_to_response
additions (images, certifications, alternative_titles) land together.

Movies are untouched: /movie/{id} has no aggregate_credits and its
`credits` is already the full cast.

Tests: departed regulars retained, newest-season arrivals appended after
show billing order, characters read from roles[], no duplicates across
the two payloads, graceful fallback for pre-aggregate cache rows.

Refs docs/architecture/tmdb-roadmap.md A2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): reserve cast slots so newest-season arrivals survive the cap

The union was appended aggregate-first and then truncated to ten, so on
exactly the shows it was built for — long-running ones, where the
whole-run cast alone exceeds the limit — every newest-season arrival was
sliced back off. The original fixture had two aggregate members and
could not catch it.

unifiedTvCast now holds back up to three slots for the top-billed
arrivals instead of appending them where the cap discards them, and
gives the slots back when nobody is new.

Tests: a 12-member aggregate plus two arrivals keeps both arrivals and
top billing; an aggregate with no arrivals still gets all ten slots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(tmdb): split the series-cast suite out of the merge spec

The merge conflict resolution put both new describes back into
tmdb-merge.spec.ts, pushing it to 499 lines — past the 400-line
max-lines cap. The aggregate-credits suite moves to its own file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): stop the cast union from shrinking, and bound what it caches

Three follow-ups from a review pass over the aggregate-credits union:

- The reserved arrival slots were subtracted from the aggregate even when
  the aggregate was shorter than the cap, so a show with four regulars and
  five newcomers returned seven names instead of nine. The reservation is
  a floor for arrivals now, not a quota.
- An aggregate member's character came from the first role with any text,
  so a one-episode cameo could outrank the part the actor is known for.
  Pick the role with the most episodes.
- aggregate_credits carries a show's whole-run cast AND crew, and details
  payloads are cached verbatim — orders of magnitude of JSON for a list
  the merge truncates to ten people. Cache the billing-order prefix and
  drop the crew nothing reads.

Extracting the people-related helpers into tmdb-credits.ts keeps
tmdb-merge.ts under the line cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): keep the aggregate ids the arrival check depends on

Trimming the cached cast to its top 40 broke the property it was supposed
to preserve: `known` is built from the aggregate ids, so a returning actor
billed below the cut read as a new arrival on the cached path and took a
reserved slot. The same show then showed a different top ten on its second
open than on its first.

Keep the whole cast, and cut the two things nothing reads instead: the
aggregate crew, and every `roles[]` entry except the one the merge picks
(most episodes). A merge over the trimmed payload now provably returns
what a merge over the full one does — covered by a test that runs both.

Also points CLAUDE.md and the doc's module table at tmdb-credits.ts, where
the credit helpers now live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(tmdb): add the release note for the series-cast fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): let the cache trim reuse the merge's own role choice

The trim picked the role with the most episodes; the merge picks the
NAMED role with the most episodes. TMDB uses unnamed roles for uncredited
appearances, so a member whose blank role outranked their real one lost
their character on every render after the first.

Both now call pickAggregateRole, which is the point — two copies of the
same choice are what let them drift.

Also adds a test pinning the property the earlier truncation defect broke:
the displayed cast is the cap or everyone available, whichever is smaller.
Which people make the cut at the cap is the reservation's job and is
deliberate; the count is not negotiable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(tmdb): state the aggregate-credits contract as TMDB actually words it

TMDB describes the endpoint in one sentence that contradicts itself: "it
does not return the newest season. Instead, it is a view of all the entire
cast & crew for all episodes belonging to a TV show." The doc and the code
comment asserted the first half as settled fact.

The union never depended on that reading — arrivals are a set difference,
so under "whole run" they are simply empty — but the comment implied an
assumption the code does not make. Say what TMDB says, note the ambiguity,
and note why either reading is safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.8 authored and GitHub committed 2026-07-26 01:55:44 +02:00
1 parent c0e065da17
commit a5bb8dc25c
12 files changed
+740 -99

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: tmdb
---
Series detail pages now list the cast of the whole show instead of only its
newest season, so actors who left partway through stop disappearing from
long-running shows — while people who joined for the current season still
show up.
+1 -1
View File
@@ -833,7 +833,7 @@ engine` (restart required) or
- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`)
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint
- Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday`
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-merge.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
- Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts`
- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)
- Match confidence: a provider `tmdb_id` is a strong hint, not gospel — its payload is weighed against the item (`assessProviderId`: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (`badProviderId:<id>` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment
+15 -1
View File
@@ -44,6 +44,8 @@ store imports):
| `tmdb-matcher.ts` | Title normalization, year extraction, and the match-confidence gate (pure functions) |
| `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory LRU capped at 300 entries) with caller-supplied TTLs |
| `tmdb-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) |
| `tmdb-credits.ts` | People out of credit payloads: display cast, person chips, and the two-shape union a series cast needs |
| `tmdb-cache-payload.ts` | Trims a details payload before caching (aggregate roles/crew) without changing what a merge over it produces |
| `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution |
| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups |
| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:<id>` rows) |
@@ -149,7 +151,19 @@ are still fetched in the app language afterwards.
Details are fetched with
`/movie/{id}?append_to_response=credits,videos,recommendations` (`/tv/{id}`
for series). Credits provide cast/director; videos supply the best YouTube
for series). Credits provide cast/director — for series the request also
appends `aggregate_credits`, because TMDB documents a TV id's `credits` as
the **latest season's** credits. What `aggregate_credits` covers is
described in one self-contradicting sentence — "it does not return the
newest season. Instead, it is a view of all the entire cast & crew for all
episodes belonging to a TV show" — so it is either the whole run minus the
newest season or the whole run. `unifiedTvCast` is written not to care:
it unions the two by set difference (whole-run billing order first, then
anyone `credits` has that the aggregate lacks), which under the second
reading appends nothing. Either way, long-running shows neither lose
departed regulars nor miss new ones. Payloads cached
before this landed have no `aggregate_credits` and fall back to the old
behaviour until they are refetched; videos supply the best YouTube
trailer (official trailer > trailer > teaser, merged into
`youtube_trailer` / `tmdb_trailer`); recommendations power the "Similar"
rail. In Xtream detail views the rail shows only recommendations that
@@ -90,7 +90,14 @@ export class TmdbApiService {
): Promise<TmdbTvDetails> {
return this.request<TmdbTvDetails>(
`/tv/${tmdbId}`,
{ language, append_to_response: 'credits,videos,recommendations' },
{
language,
// aggregate_credits spans the whole run; plain `credits` on
// a TV id is documented as the LATEST SEASON only, so both
// are needed to reconstruct the real cast (see tmdb-merge)
append_to_response:
'credits,aggregate_credits,videos,recommendations',
},
apiKey
);
}
@@ -0,0 +1,175 @@
import { mergeSerieInfoWithTmdb } from './tmdb-merge';
import { trimDetailsForCache } from './tmdb-cache-payload';
import { XtreamSerieInfo } from '@iptvnator/shared/interfaces';
import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types';
describe('trimDetailsForCache', () => {
it('passes a movie payload through untouched', () => {
const movie: TmdbMovieDetails = {
id: 603,
title: 'The Matrix',
credits: { cast: [{ name: 'Keanu Reeves' }] },
};
expect(trimDetailsForCache(movie)).toBe(movie);
});
it('keeps every aggregate cast member', () => {
const details: TmdbTvDetails = {
id: 76479,
aggregate_credits: {
cast: Array.from({ length: 120 }, (_, i) => ({
id: i,
name: `Actor ${i}`,
order: i,
})),
},
};
const trimmed = trimDetailsForCache(details) as TmdbTvDetails;
// The tail is what tells a returning actor from a new arrival
expect(trimmed.aggregate_credits?.cast).toHaveLength(120);
});
it('reduces roles to the character with the most episodes', () => {
const details: TmdbTvDetails = {
id: 76479,
aggregate_credits: {
cast: [
{
id: 1,
name: 'Karl Urban',
order: 0,
roles: [
{ character: 'Cameo Guy', episode_count: 1 },
{ character: 'Billy Butcher', episode_count: 32 },
],
},
],
},
};
const trimmed = trimDetailsForCache(details) as TmdbTvDetails;
expect(trimmed.aggregate_credits?.cast?.[0].roles).toEqual([
{ character: 'Billy Butcher', episode_count: 32 },
]);
});
it('keeps the named role even when a blank one has more episodes', () => {
// TMDB uses unnamed roles for uncredited appearances; the merge
// skips them, so the trim must not cache one in their place
const details: TmdbTvDetails = {
id: 76479,
aggregate_credits: {
cast: [
{
id: 1,
name: 'Karl Urban',
order: 0,
roles: [
{ character: '', episode_count: 40 },
{ character: 'Billy Butcher', episode_count: 32 },
],
},
],
},
};
const trimmed = trimDetailsForCache(details) as TmdbTvDetails;
expect(trimmed.aggregate_credits?.cast?.[0].roles).toEqual([
{ character: 'Billy Butcher', episode_count: 32 },
]);
});
it('drops the aggregate crew nothing reads', () => {
const details = {
id: 76479,
aggregate_credits: {
cast: [{ id: 1, name: 'Karl Urban', order: 0 }],
crew: Array.from({ length: 500 }, (_, i) => ({
id: i,
name: `Crew ${i}`,
})),
},
} as TmdbTvDetails;
const trimmed = trimDetailsForCache(details) as TmdbTvDetails & {
aggregate_credits?: { crew?: unknown[] };
};
expect(trimmed.aggregate_credits?.crew).toBeUndefined();
expect(trimmed.aggregate_credits?.cast).toHaveLength(1);
});
it('does not mutate the payload the caller still uses', () => {
const details: TmdbTvDetails = {
id: 76479,
aggregate_credits: {
cast: [
{
id: 1,
name: 'Karl Urban',
roles: [
{ character: 'A', episode_count: 1 },
{ character: 'B', episode_count: 2 },
],
},
],
},
};
trimDetailsForCache(details);
expect(details.aggregate_credits?.cast?.[0].roles).toHaveLength(2);
});
it('merges a trimmed payload to the same cast as the full one', () => {
// The property that matters: the first render (network payload) and
// every later one (cached payload) must show the same people
const info = {
name: 'The Boys',
cover: '',
plot: '',
cast: '',
director: '',
genre: '',
releaseDate: '',
last_modified: '',
rating: '',
rating_5based: 0,
backdrop_path: [],
youtube_trailer: '',
episode_run_time: '',
category_id: '1',
} as XtreamSerieInfo;
const details: TmdbTvDetails = {
id: 76479,
aggregate_credits: {
cast: Array.from({ length: 60 }, (_, i) => ({
id: i,
name: `Regular ${i}`,
order: i,
roles: [{ character: `Role ${i}`, episode_count: 10 }],
})),
},
credits: {
// A long-serving actor billed 50th who is still in the
// newest season — dropping the aggregate tail would have
// promoted them into a reserved arrival slot
cast: [{ id: 50, name: 'Regular 50', order: 0 }],
},
};
const fromFull = mergeSerieInfoWithTmdb(info, details);
const fromCache = mergeSerieInfoWithTmdb(
info,
trimDetailsForCache(details) as TmdbTvDetails
);
expect(fromCache.cast).toBe(fromFull.cast);
expect(fromCache.cast).not.toContain('Regular 50');
});
});
@@ -0,0 +1,47 @@
import { pickAggregateRole } from './tmdb-credits';
import { TmdbDetails, TmdbTvDetails } from './tmdb.types';
/**
* Trims a details payload before it is cached.
*
* `aggregate_credits` spans a show's whole run: a crew list that can reach
* into the thousands, plus every cast member carrying one `roles[]` entry
* per character they ever played. Caching that verbatim grows
* `tmdb_metadata` (and the PWA's in-memory map) by orders of magnitude for
* data nothing reads.
*
* What goes is chosen so that a merge over the trimmed payload produces
* exactly what a merge over the full one would:
*
* - the whole cast stays, ids included. It is the only record of who was
* in the show before the newest season, so dropping the tail would make
* a returning actor read as a new arrival on the cached path — the
* displayed cast would then differ between the first render and every
* later one.
* - `roles[]` keeps only the entry the merge itself would pick — the
* named character with the most episodes, chosen by the very same
* function, so the two can never drift apart.
* - `crew` goes entirely: series credits come from `created_by`, so
* nothing reads it.
*
* Everything else is passed through untouched — payloads still hold
* whatever a later phase might want without a refetch.
*/
export function trimDetailsForCache(details: TmdbDetails): TmdbDetails {
const aggregate = (details as TmdbTvDetails).aggregate_credits;
if (!aggregate) {
return details;
}
const cast = (aggregate.cast ?? []).map((member) => {
const roles = member.roles ?? [];
if (roles.length <= 1) {
return member;
}
// The merge's own choice, not a second one that could differ
const main = pickAggregateRole(member);
return { ...member, roles: main ? [main] : [] };
});
return { ...details, aggregate_credits: { cast } } as TmdbDetails;
}
+197
View File
@@ -0,0 +1,197 @@
import { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
import { tmdbProfileUrl } from './tmdb-config';
import { TmdbCastMember, TmdbCredits, TmdbTvDetails } from './tmdb.types';
/**
* Extraction of people from TMDB credit payloads: the display cast, the
* clickable person chips, and the two-shape union a series cast needs.
* Pure functions — the field-level merge itself lives in `tmdb-merge.ts`.
*/
const MAX_CAST_NAMES = 10;
/**
* Slots held back for people who appear only in the newest season. On a
* long-running show the whole-run cast alone fills the display limit, so
* without a reservation the arrivals this union exists to preserve would
* be sliced straight back off.
*/
const RESERVED_NEW_SEASON_SLOTS = 3;
const byBillingOrder = (a: { order?: number }, b: { order?: number }) =>
(a.order ?? 0) - (b.order ?? 0);
export function limitCast(cast: TmdbCastMember[]): TmdbCastMember[] {
return cast
.slice(0, MAX_CAST_NAMES)
.filter((member) => Boolean(member.name));
}
export function topCast(credits: TmdbCredits | undefined): TmdbCastMember[] {
return limitCast([...(credits?.cast ?? [])].sort(byBillingOrder));
}
/**
* The role an aggregate member is actually known for. A returning actor
* accumulates one entry per character, so a single-episode cameo sits in
* `roles[]` next to the lead they played for ten seasons — pick by episode
* count rather than by array position. Unnamed roles are skipped: TMDB
* uses them for uncredited appearances, and they would otherwise beat a
* real character on episode count alone.
*
* Exported because the cache trim keeps exactly this role and discards
* the rest — sharing the choice is what stops a cached payload from
* displaying a different character than the payload it was written from.
*/
export function pickAggregateRole<
T extends { character?: string; episode_count?: number },
>(member: { roles?: T[] }): T | undefined {
const named = (member.roles ?? []).filter((role) => role.character?.trim());
if (named.length === 0) {
return undefined;
}
return named.reduce((best, role) =>
(role.episode_count ?? 0) > (best.episode_count ?? 0) ? role : best
);
}
function aggregateCharacter(member: {
roles?: { character?: string; episode_count?: number }[];
}): string | undefined {
return pickAggregateRole(member)?.character;
}
/**
* The cast of a SERIES, reconstructed from the two shapes TMDB offers.
*
* `/tv/{id}` `credits` is documented as the credits of the LATEST SEASON.
* TMDB describes `aggregate_credits` in one self-contradicting sentence —
* "it does not return the newest season. Instead, it is a view of all the
* entire cast & crew for all episodes belonging to a TV show" — so it is
* either the whole run minus the newest season, or the whole run. This
* union does not care which: whoever `credits` has and `aggregate_credits`
* does not is missing from the whole-run view and is appended, and under
* the second reading that set is simply empty. Either way, long-running
* shows neither lose departed regulars nor miss new arrivals.
*/
export function unifiedTvCast(details: TmdbTvDetails): TmdbCastMember[] {
const aggregate: TmdbCastMember[] = (details.aggregate_credits?.cast ?? [])
.filter((member) => Boolean(member.name))
.sort(byBillingOrder)
.map((member) => ({
id: member.id,
name: member.name,
character: aggregateCharacter(member),
order: member.order,
profile_path: member.profile_path,
}));
// Cache rows written before aggregate_credits was requested, and shows
// TMDB has no aggregate for, still work — they just keep the old cast.
if (aggregate.length === 0) {
return [...(details.credits?.cast ?? [])].sort(byBillingOrder);
}
const known = new Set(
aggregate
.map((member) => member.id)
.filter((id): id is number => id !== undefined)
);
// Newest-season arrivals go last on purpose: their `order` is scoped to
// that season and would otherwise outrank show-level billing.
const arrivals = [...(details.credits?.cast ?? [])]
.filter(
(member) =>
Boolean(member.name) &&
(member.id === undefined || !known.has(member.id))
)
.sort(byBillingOrder);
if (arrivals.length === 0) {
return aggregate;
}
// Reserve room for the top-billed arrivals rather than appending them
// where the cap will discard them. The reservation is a floor, not a
// quota: whatever the aggregate leaves unused still goes to arrivals,
// so a short whole-run cast never shrinks the list below the cap.
const reserved = Math.min(arrivals.length, RESERVED_NEW_SEASON_SLOTS);
const fromAggregate = aggregate.slice(
0,
Math.max(0, MAX_CAST_NAMES - reserved)
);
return [
...fromAggregate,
...arrivals.slice(0, MAX_CAST_NAMES - fromAggregate.length),
];
}
export function castNames(cast: TmdbCastMember[]): string {
return cast.map((member) => member.name).join(', ');
}
/** Cast with profile photos for the avatar chips in detail views */
export function enrichedCast(cast: TmdbCastMember[]): TmdbEnrichedCastMember[] {
return cast.map((member) => ({
name: member.name,
...(member.character ? { character: member.character } : {}),
profileUrl: tmdbProfileUrl(member.profile_path),
...(member.id ? { tmdbPersonId: member.id } : {}),
}));
}
export function directorNames(credits: TmdbCredits | undefined): string {
return (credits?.crew ?? [])
.filter((member) => member.job === 'Director')
.map((member) => member.name)
.filter(Boolean)
.join(', ');
}
export function creatorNames(details: TmdbTvDetails): string {
return (details.created_by ?? [])
.map((creator) => creator.name)
.filter(Boolean)
.join(', ');
}
/**
* Directors (movies) as clickable person chips — same shape as the cast
* chips, so a director opens the same person page as an actor. Deduped by
* TMDB id to collapse the duplicate crew rows TMDB sometimes returns.
*/
export function enrichedDirectors(
credits: TmdbCredits | undefined
): TmdbEnrichedCastMember[] {
const seen = new Set<number>();
const directors: TmdbEnrichedCastMember[] = [];
for (const member of credits?.crew ?? []) {
if (member.job !== 'Director' || !member.name) {
continue;
}
if (member.id !== undefined) {
if (seen.has(member.id)) {
continue;
}
seen.add(member.id);
}
directors.push({
name: member.name,
profileUrl: tmdbProfileUrl(member.profile_path),
...(member.id ? { tmdbPersonId: member.id } : {}),
});
}
return directors;
}
/** Series creators as clickable person chips (TV shows have no director) */
export function enrichedCreators(details: TmdbTvDetails): TmdbEnrichedCastMember[] {
return (details.created_by ?? [])
.filter((creator) => Boolean(creator.name))
.map((creator) => ({
name: creator.name,
profileUrl: tmdbProfileUrl(creator.profile_path),
...(creator.id ? { tmdbPersonId: creator.id } : {}),
}));
}
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { TmdbMediaType } from '@iptvnator/shared/interfaces';
import { TmdbApiService, isTmdbNotFound } from './tmdb-api.service';
import { trimDetailsForCache } from './tmdb-cache-payload';
import { TmdbCacheService } from './tmdb-cache.service';
import { TMDB_DETAILS_CACHE_TTL_MS } from './tmdb-config';
import {
@@ -283,7 +284,9 @@ export class TmdbEnrichmentService {
lookupKey,
language,
tmdbId,
payload: JSON.stringify(details),
payload: JSON.stringify(
details === null ? null : trimDetailsForCache(details)
),
});
return details;
@@ -0,0 +1,236 @@
import { XtreamSerieInfo } from '@iptvnator/shared/interfaces';
import { mergeSerieInfoWithTmdb } from './tmdb-merge';
import { TmdbTvDetails } from './tmdb.types';
describe('series cast (aggregate + latest season)', () => {
const info: XtreamSerieInfo = {
name: 'The Boys',
cover: '',
plot: '',
cast: '',
director: '',
genre: '',
releaseDate: '',
last_modified: '',
rating: '',
rating_5based: 0,
backdrop_path: [],
youtube_trailer: '',
episode_run_time: '',
category_id: '1',
};
// TMDB documents /tv/{id} `credits` as the LATEST SEASON only, and
// aggregate_credits as everything EXCEPT the newest season.
const details: TmdbTvDetails = {
id: 76479,
name: 'The Boys',
aggregate_credits: {
cast: [
{
id: 1,
name: 'Karl Urban',
order: 0,
profile_path: '/urban.jpg',
roles: [{ character: 'Billy Butcher' }],
},
{
id: 2,
name: 'Jack Quaid',
order: 1,
roles: [{ character: 'Hughie' }],
},
],
},
credits: {
cast: [
// Still around in the newest season
{ id: 1, name: 'Karl Urban', order: 0, character: 'Butcher' },
// Joined only in the newest season
{
id: 3,
name: 'Newcomer Person',
order: 1,
character: 'Rookie',
},
],
},
};
it('keeps whole-run cast that the latest season dropped', () => {
const merged = mergeSerieInfoWithTmdb(info, details);
// Jack Quaid is absent from `credits` — the old code lost him
expect(merged.cast).toContain('Jack Quaid');
});
it('adds newest-season arrivals after the show billing order', () => {
const merged = mergeSerieInfoWithTmdb(info, details);
expect(merged.cast).toBe('Karl Urban, Jack Quaid, Newcomer Person');
});
it('takes the character from the aggregate roles array', () => {
const merged = mergeSerieInfoWithTmdb(info, details);
expect(merged.tmdb_cast?.[0]).toEqual({
name: 'Karl Urban',
character: 'Billy Butcher',
profileUrl: 'https://image.tmdb.org/t/p/w185/urban.jpg',
tmdbPersonId: 1,
});
});
it('does not duplicate people present in both payloads', () => {
const merged = mergeSerieInfoWithTmdb(info, details);
expect(
merged.tmdb_cast?.filter((m) => m.name === 'Karl Urban')
).toHaveLength(1);
});
it('keeps newest-season arrivals when the aggregate already fills the cap', () => {
// The case the union exists for: a long-running show whose
// whole-run cast alone exceeds the display limit
const bigAggregate = Array.from({ length: 12 }, (_, i) => ({
id: 100 + i,
name: `Regular ${i}`,
order: i,
roles: [{ character: `Role ${i}` }],
}));
const merged = mergeSerieInfoWithTmdb(info, {
id: 76479,
name: 'The Boys',
aggregate_credits: { cast: bigAggregate },
credits: {
cast: [
{ id: 900, name: 'Brand New Lead', order: 0 },
{ id: 901, name: 'Brand New Sidekick', order: 1 },
],
},
});
const names = merged.tmdb_cast?.map((member) => member.name) ?? [];
expect(names).toHaveLength(10);
expect(names).toContain('Brand New Lead');
expect(names).toContain('Brand New Sidekick');
// Top billing survives; the reservation eats into the tail only
expect(names[0]).toBe('Regular 0');
});
it('gives every slot to the aggregate when nobody is new', () => {
const bigAggregate = Array.from({ length: 12 }, (_, i) => ({
id: 100 + i,
name: `Regular ${i}`,
order: i,
}));
const merged = mergeSerieInfoWithTmdb(info, {
id: 76479,
name: 'The Boys',
aggregate_credits: { cast: bigAggregate },
credits: { cast: [{ id: 100, name: 'Regular 0', order: 0 }] },
});
expect(merged.tmdb_cast).toHaveLength(10);
expect(merged.tmdb_cast?.[9].name).toBe('Regular 9');
});
it('does not shrink the list when the whole-run cast is short', () => {
// The reservation is a floor for arrivals, not a ceiling: with four
// regulars and five newcomers all nine fit under the cap
const merged = mergeSerieInfoWithTmdb(info, {
id: 76479,
name: 'The Boys',
aggregate_credits: {
cast: Array.from({ length: 4 }, (_, i) => ({
id: 100 + i,
name: `Regular ${i}`,
order: i,
})),
},
credits: {
cast: Array.from({ length: 5 }, (_, i) => ({
id: 200 + i,
name: `Newcomer ${i}`,
order: i,
})),
},
});
expect(merged.tmdb_cast).toHaveLength(9);
expect(merged.cast).toContain('Newcomer 4');
});
it('takes the role the actor played the most episodes of', () => {
// A one-episode cameo sits in roles[] next to the lead part
const merged = mergeSerieInfoWithTmdb(info, {
id: 76479,
name: 'The Boys',
aggregate_credits: {
cast: [
{
id: 1,
name: 'Karl Urban',
order: 0,
roles: [
{ character: 'Cameo Guy', episode_count: 1 },
{ character: 'Billy Butcher', episode_count: 32 },
],
},
],
},
});
expect(merged.tmdb_cast?.[0].character).toBe('Billy Butcher');
});
it('always fills the cap when there are enough people to fill it', () => {
// The reservation decides WHO makes the cut, never how many: the
// list is the display limit or everyone available, whichever is
// smaller. The earlier defect broke exactly this.
const cast = (n: number, offset: number, label: string) =>
Array.from({ length: n }, (_, i) => ({
id: offset + i,
name: `${label} ${i}`,
order: i,
}));
for (const [regulars, newcomers] of [
[0, 4],
[2, 1],
[4, 5],
[7, 5],
[9, 4],
[12, 2],
[12, 0],
]) {
const merged = mergeSerieInfoWithTmdb(info, {
id: 76479,
name: 'The Boys',
aggregate_credits: { cast: cast(regulars, 100, 'Regular') },
credits: { cast: cast(newcomers, 900, 'Newcomer') },
});
expect({
regulars,
newcomers,
shown: merged.tmdb_cast?.length ?? 0,
}).toEqual({
regulars,
newcomers,
shown: Math.min(10, regulars + newcomers),
});
}
});
it('falls back to plain credits when no aggregate is present', () => {
// Cache rows written before aggregate_credits was requested
const merged = mergeSerieInfoWithTmdb(info, {
id: 76479,
name: 'The Boys',
credits: {
cast: [
{ id: 3, name: 'Second Billed', order: 1 },
{ id: 1, name: 'Top Billed', order: 0 },
],
},
});
expect(merged.cast).toBe('Top Billed, Second Billed');
});
});
@@ -2,7 +2,9 @@ import { XtreamSerieInfo, XtreamVodInfo } from '@iptvnator/shared/interfaces';
import { mergeSerieInfoWithTmdb, mergeVodInfoWithTmdb } from './tmdb-merge';
import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types';
function providerVodInfo(overrides: Partial<XtreamVodInfo> = {}): XtreamVodInfo {
function providerVodInfo(
overrides: Partial<XtreamVodInfo> = {}
): XtreamVodInfo {
return {
kinopoisk_url: '',
tmdb_id: '',
@@ -183,9 +185,7 @@ describe('mergeVodInfoWithTmdb', () => {
expect(merged.rating).toBe(7);
expect(merged.movie_image).toBe('http://provider/poster.jpg');
expect(merged.tmdb_cast).toBeUndefined();
expect(merged.backdrop_path).toEqual([
'http://provider/backdrop.jpg',
]);
expect(merged.backdrop_path).toEqual(['http://provider/backdrop.jpg']);
});
it('ignores TMDB rating without votes', () => {
+25 -91
View File
@@ -1,16 +1,25 @@
import {
normalizeSeriesStatus,
StalkerVodInfo,
TmdbEnrichedCastMember,
TmdbMediaType,
TmdbRecommendation,
XtreamSerieInfo,
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import { tmdbBackdropUrl, tmdbPosterUrl, tmdbProfileUrl } from './tmdb-config';
import { tmdbBackdropUrl, tmdbPosterUrl } from './tmdb-config';
import {
castNames,
creatorNames,
directorNames,
enrichedCast,
enrichedCreators,
enrichedDirectors,
limitCast,
topCast,
unifiedTvCast,
} from './tmdb-credits';
import { extractYear } from './tmdb-matcher';
import {
TmdbCredits,
TmdbDetails,
TmdbMovieDetails,
TmdbTvDetails,
@@ -21,90 +30,9 @@ import {
* The provider stays authoritative for stream-related data; TMDB wins for
* editorial fields (plot, cast, director, genres, rating, artwork) when it
* has a value, otherwise the provider value is kept. Nothing is mutated.
* People (cast, directors, creators) are extracted in `tmdb-credits.ts`.
*/
const MAX_CAST_NAMES = 10;
function topCast(credits: TmdbCredits | undefined) {
return [...(credits?.cast ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.slice(0, MAX_CAST_NAMES)
.filter((member) => Boolean(member.name));
}
function castNames(credits: TmdbCredits | undefined): string {
return topCast(credits)
.map((member) => member.name)
.join(', ');
}
/** Cast with profile photos for the avatar chips in detail views */
function enrichedCast(
credits: TmdbCredits | undefined
): TmdbEnrichedCastMember[] {
return topCast(credits).map((member) => ({
name: member.name,
...(member.character ? { character: member.character } : {}),
profileUrl: tmdbProfileUrl(member.profile_path),
...(member.id ? { tmdbPersonId: member.id } : {}),
}));
}
function directorNames(credits: TmdbCredits | undefined): string {
return (credits?.crew ?? [])
.filter((member) => member.job === 'Director')
.map((member) => member.name)
.filter(Boolean)
.join(', ');
}
function creatorNames(details: TmdbTvDetails): string {
return (details.created_by ?? [])
.map((creator) => creator.name)
.filter(Boolean)
.join(', ');
}
/**
* Directors (movies) as clickable person chips — same shape as the cast
* chips, so a director opens the same person page as an actor. Deduped by
* TMDB id to collapse the duplicate crew rows TMDB sometimes returns.
*/
function enrichedDirectors(
credits: TmdbCredits | undefined
): TmdbEnrichedCastMember[] {
const seen = new Set<number>();
const directors: TmdbEnrichedCastMember[] = [];
for (const member of credits?.crew ?? []) {
if (member.job !== 'Director' || !member.name) {
continue;
}
if (member.id !== undefined) {
if (seen.has(member.id)) {
continue;
}
seen.add(member.id);
}
directors.push({
name: member.name,
profileUrl: tmdbProfileUrl(member.profile_path),
...(member.id ? { tmdbPersonId: member.id } : {}),
});
}
return directors;
}
/** Series creators as clickable person chips (TV shows have no director) */
function enrichedCreators(details: TmdbTvDetails): TmdbEnrichedCastMember[] {
return (details.created_by ?? [])
.filter((creator) => Boolean(creator.name))
.map((creator) => ({
name: creator.name,
profileUrl: tmdbProfileUrl(creator.profile_path),
...(creator.id ? { tmdbPersonId: creator.id } : {}),
}));
}
const MAX_RECOMMENDATIONS = 12;
/** Best YouTube trailer key: official trailer > any trailer > teaser */
@@ -170,11 +98,12 @@ export function mergeVodInfoWithTmdb(
info: XtreamVodInfo,
details: TmdbMovieDetails
): XtreamVodInfo {
const tmdbCast = enrichedCast(details.credits);
const movieCast = topCast(details.credits);
const tmdbCast = enrichedCast(movieCast);
const tmdbDirectors = enrichedDirectors(details.credits);
const trailer = pickTrailerKey(details);
const recommendations = recommendationList(details);
const cast = castNames(details.credits);
const cast = castNames(movieCast);
const director = directorNames(details.credits);
const genre = genreNames(details);
const rating = tmdbRating(details);
@@ -217,12 +146,13 @@ export function mergeSerieInfoWithTmdb(
info: XtreamSerieInfo,
details: TmdbTvDetails
): XtreamSerieInfo {
const tmdbCast = enrichedCast(details.credits);
const seriesCast = limitCast(unifiedTvCast(details));
const tmdbCast = enrichedCast(seriesCast);
const tmdbDirectors = enrichedCreators(details);
const status = normalizeSeriesStatus(details.status);
const trailer = pickTrailerKey(details);
const recommendations = recommendationList(details);
const cast = castNames(details.credits);
const cast = castNames(seriesCast);
const creators = creatorNames(details);
const genre = genreNames(details);
const rating = tmdbRating(details);
@@ -262,7 +192,11 @@ export function mergeStalkerInfoWithTmdb(
details: TmdbMovieDetails | TmdbTvDetails,
mediaType: TmdbMediaType
): StalkerVodInfo {
const tmdbCast = enrichedCast(details.credits);
const selectedCast =
mediaType === 'movie'
? topCast(details.credits)
: limitCast(unifiedTvCast(details as TmdbTvDetails));
const tmdbCast = enrichedCast(selectedCast);
const tmdbDirectors =
mediaType === 'movie'
? enrichedDirectors(details.credits)
@@ -273,7 +207,7 @@ export function mergeStalkerInfoWithTmdb(
: null;
const trailer = pickTrailerKey(details);
const recommendations = recommendationList(details);
const cast = castNames(details.credits);
const cast = castNames(selectedCast);
const director =
mediaType === 'movie'
? directorNames(details.credits)
+19
View File
@@ -57,6 +57,23 @@ export interface TmdbCredits {
crew?: TmdbCrewMember[];
}
/**
* `/tv/{id}/aggregate_credits` groups a person's work across the whole
* run, so a character lives in `roles[]` rather than on the member.
*/
export interface TmdbAggregateCastMember {
id?: number;
name: string;
order?: number;
profile_path?: string | null;
total_episode_count?: number;
roles?: { character?: string; episode_count?: number }[];
}
export interface TmdbAggregateCredits {
cast?: TmdbAggregateCastMember[];
}
export interface TmdbGenre {
id: number;
name: string;
@@ -100,6 +117,8 @@ export interface TmdbTvDetails extends TmdbDetailsBase {
first_air_date?: string;
/** English production status ("Ended", "Returning Series", ...) */
status?: string;
/** Series-wide cast; `credits` alone covers only the latest season */
aggregate_credits?: TmdbAggregateCredits;
episode_run_time?: number[];
created_by?: {
id?: number;