diff --git a/.changes/tmdb-series-cast.md b/.changes/tmdb-series-cast.md new file mode 100644 index 000000000..037ad2f8a --- /dev/null +++ b/.changes/tmdb-series-cast.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 96363b01a..a2e3b4021 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 5c30adc29..146bfa063 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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:` 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 diff --git a/libs/services/src/lib/tmdb/tmdb-api.service.ts b/libs/services/src/lib/tmdb/tmdb-api.service.ts index 8aacd83af..b2b6555b0 100644 --- a/libs/services/src/lib/tmdb/tmdb-api.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-api.service.ts @@ -90,7 +90,14 @@ export class TmdbApiService { ): Promise { return this.request( `/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 ); } diff --git a/libs/services/src/lib/tmdb/tmdb-cache-payload.spec.ts b/libs/services/src/lib/tmdb/tmdb-cache-payload.spec.ts new file mode 100644 index 000000000..d96cfdd63 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-cache-payload.spec.ts @@ -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'); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-cache-payload.ts b/libs/services/src/lib/tmdb/tmdb-cache-payload.ts new file mode 100644 index 000000000..7289855a8 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-cache-payload.ts @@ -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; +} diff --git a/libs/services/src/lib/tmdb/tmdb-credits.ts b/libs/services/src/lib/tmdb/tmdb-credits.ts new file mode 100644 index 000000000..66e48d672 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-credits.ts @@ -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(); + 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 } : {}), + })); +} diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts index 882f8a8b3..600448be5 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -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; diff --git a/libs/services/src/lib/tmdb/tmdb-merge-series-cast.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge-series-cast.spec.ts new file mode 100644 index 000000000..5afa94629 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-merge-series-cast.spec.ts @@ -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'); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts index 084ff13df..c64af3f98 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts @@ -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 { +function providerVodInfo( + overrides: Partial = {} +): 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', () => { diff --git a/libs/services/src/lib/tmdb/tmdb-merge.ts b/libs/services/src/lib/tmdb/tmdb-merge.ts index 4f309605d..13d8669c8 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.ts @@ -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(); - 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) diff --git a/libs/services/src/lib/tmdb/tmdb.types.ts b/libs/services/src/lib/tmdb/tmdb.types.ts index c6ec2b093..ccb5087af 100644 --- a/libs/services/src/lib/tmdb/tmdb.types.ts +++ b/libs/services/src/lib/tmdb/tmdb.types.ts @@ -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;