mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
c0e065da17
commit
a5bb8dc25c
12 files changed
+740
-99
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user