mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
feat(tmdb): clickable director/creator chips and directing credits on person pages (#1227)
* feat(tmdb): clickable director/creator chips and directing credits on person pages Directors were plain merged text — no photos, no navigation — while the data was already sitting in the cached TMDB payloads (credits.crew and created_by both carry id + profile_path; they just were not typed or parsed). - tmdb-merge: enrichedDirectors (crew, job === 'Director', deduped by person id) and enrichedCreators (created_by) produce the same chip shape as the cast (TmdbEnrichedCastMember) into a new tmdb_directors field on all three merges (Xtream VOD, Xtream series, Stalker); types widened (crew id/profile_path, created_by id/profile_path). - Detail views (shared VodDetailsComponent, Xtream vod/serial routes, Stalker series view) render the Director row as clickable avatar chips when tmdb_directors is present — same markup and openActor handler as the cast strip — falling back to the plain text otherwise. Stalker re-normalization allowlist preserves the new field. - Person pages: mapPersonFilmography now merges combined_credits.crew (jobs Director/Creator) into the filmography — acting wins the per-title dedup, directing-only titles show the job in the character slot. Everything else (library matching, All-portals scope, filters, search fallback, back button) works unchanged because the person page is role-agnostic. Existing caches work as-is: crew/created_by were always part of the stored payloads. Tests: merge spec (director/creator chips + crew-row dedup ×3 merges), person spec (crew credits, Producer excluded, acting-wins dedup), stalker-vod.utils passthrough. Docs updated (CLAUDE.md + architecture). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): address director-pages review — split oversized spec, stable track keys, translated crew roles - tmdb-merge.spec.ts grew past the 400-line lint ceiling — the Stalker merge suite moved to tmdb-merge-stalker.spec.ts (fixes the CI Lint job). - All cast/director chip loops now track by TMDB person id with an index fallback ('p<id>' / 'i<index>') instead of member.name — distinct people can share a name and creator payloads carry no dedup (greptile). - Directing-only filmography credits carry the role in a new crewJob field ('Director' | 'Creator') instead of stuffing TMDB's raw English job into character; ActorViewComponent renders it through translated labels (XTREAM.CREW_JOB_DIRECTOR/CREATOR, added to all 18 locales via the i18n patch workflow, matching each locale's existing glossary — pt "Diretor", de "Regisseur") (Codex). Tests: person spec asserts character/crewJob separation; merge suites green after the split (15 + stalker file). 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
59c15493a7
commit
5aa44d19d4
40 files changed
+528
-154
No files matched your search
@@ -86,3 +86,7 @@ vendor/embedded-mpv/*/include/
|
||||
vendor/embedded-mpv/*/lib/
|
||||
vendor/embedded-mpv/*/notices/
|
||||
vendor/embedded-mpv/*/runtime-manifest.json
|
||||
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
entities.json
|
||||
@@ -805,7 +805,7 @@ engine` (restart required) or
|
||||
- "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation
|
||||
- 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`)
|
||||
- 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
|
||||
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; 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-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 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); 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: provider `tmdb_id` trusted fully; otherwise normalized-title + year (±1) search with a strict gate — no confident match means no enrichment
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "الممثلون",
|
||||
"DIRECTOR": "المخرج",
|
||||
"SIMILAR": "مشابه",
|
||||
"CREW_JOB_DIRECTOR": "المخرج",
|
||||
"CREW_JOB_CREATOR": "المبتكر",
|
||||
"ACTOR_FILMOGRAPHY": "الأعمال الفنية",
|
||||
"ACTOR_FILTER_ALL": "الكل",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "هذه البوابة",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "الممثلون",
|
||||
"DIRECTOR": "المخرج",
|
||||
"SIMILAR": "بحال هادو",
|
||||
"CREW_JOB_DIRECTOR": "المخرج",
|
||||
"CREW_JOB_CREATOR": "المبتكر",
|
||||
"ACTOR_FILMOGRAPHY": "الأفلام ديالو",
|
||||
"ACTOR_FILTER_ALL": "كلشي",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "هاد البوابة",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Акцёры",
|
||||
"DIRECTOR": "Рэжысёр",
|
||||
"SIMILAR": "Падобнае",
|
||||
"CREW_JOB_DIRECTOR": "Рэжысёр",
|
||||
"CREW_JOB_CREATOR": "Стваральнік",
|
||||
"ACTOR_FILMOGRAPHY": "Фільмаграфія",
|
||||
"ACTOR_FILTER_ALL": "Усе",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "Гэты партал",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Schauspieler",
|
||||
"DIRECTOR": "Regisseur",
|
||||
"SIMILAR": "Ähnliche Titel",
|
||||
"CREW_JOB_DIRECTOR": "Regisseur",
|
||||
"CREW_JOB_CREATOR": "Schöpfer",
|
||||
"ACTOR_FILMOGRAPHY": "Filmografie",
|
||||
"ACTOR_FILTER_ALL": "Alle",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "Dieses Portal",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Ηθοποιοί",
|
||||
"DIRECTOR": "Σκηνοθέτης",
|
||||
"SIMILAR": "Παρόμοια",
|
||||
"CREW_JOB_DIRECTOR": "Σκηνοθέτης",
|
||||
"CREW_JOB_CREATOR": "Δημιουργός",
|
||||
"ACTOR_FILMOGRAPHY": "Φιλμογραφία",
|
||||
"ACTOR_FILTER_ALL": "Όλα",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "Αυτή η πύλη",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Actors",
|
||||
"DIRECTOR": "Director",
|
||||
"SIMILAR": "Similar",
|
||||
"CREW_JOB_DIRECTOR": "Director",
|
||||
"CREW_JOB_CREATOR": "Creator",
|
||||
"ACTOR_FILMOGRAPHY": "Filmography",
|
||||
"ACTOR_FILTER_ALL": "All",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "This portal",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Actores",
|
||||
"DIRECTOR": "Director",
|
||||
"SIMILAR": "Similares",
|
||||
"CREW_JOB_DIRECTOR": "Director",
|
||||
"CREW_JOB_CREATOR": "Creador",
|
||||
"ACTOR_FILMOGRAPHY": "Filmografía",
|
||||
"ACTOR_FILTER_ALL": "Todas",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "Este portal",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Acteurs",
|
||||
"DIRECTOR": "Réalisateur",
|
||||
"SIMILAR": "Similaires",
|
||||
"CREW_JOB_DIRECTOR": "Réalisateur",
|
||||
"CREW_JOB_CREATOR": "Créateur",
|
||||
"ACTOR_FILMOGRAPHY": "Filmographie",
|
||||
"ACTOR_FILTER_ALL": "Tout",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "Ce portail",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Attori",
|
||||
"DIRECTOR": "Regista",
|
||||
"SIMILAR": "Simili",
|
||||
"CREW_JOB_DIRECTOR": "Regista",
|
||||
"CREW_JOB_CREATOR": "Ideatore",
|
||||
"ACTOR_FILMOGRAPHY": "Filmografia",
|
||||
"ACTOR_FILTER_ALL": "Tutti",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "Questo portale",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "出演者",
|
||||
"DIRECTOR": "監督",
|
||||
"SIMILAR": "類似作品",
|
||||
"CREW_JOB_DIRECTOR": "監督",
|
||||
"CREW_JOB_CREATOR": "クリエイター",
|
||||
"ACTOR_FILMOGRAPHY": "出演作品",
|
||||
"ACTOR_FILTER_ALL": "すべて",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "このポータル",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "배우",
|
||||
"DIRECTOR": "감독",
|
||||
"SIMILAR": "비슷한 콘텐츠",
|
||||
"CREW_JOB_DIRECTOR": "감독",
|
||||
"CREW_JOB_CREATOR": "크리에이터",
|
||||
"ACTOR_FILMOGRAPHY": "출연 작품",
|
||||
"ACTOR_FILTER_ALL": "전체",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "현재 포털",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Acteurs",
|
||||
"DIRECTOR": "Regisseur",
|
||||
"SIMILAR": "Vergelijkbaar",
|
||||
"CREW_JOB_DIRECTOR": "Regisseur",
|
||||
"CREW_JOB_CREATOR": "Bedenker",
|
||||
"ACTOR_FILMOGRAPHY": "Filmografie",
|
||||
"ACTOR_FILTER_ALL": "Alles",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "Dit portaal",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Aktorzy",
|
||||
"DIRECTOR": "Reżyser",
|
||||
"SIMILAR": "Podobne",
|
||||
"CREW_JOB_DIRECTOR": "Reżyser",
|
||||
"CREW_JOB_CREATOR": "Twórca",
|
||||
"ACTOR_FILMOGRAPHY": "Filmografia",
|
||||
"ACTOR_FILTER_ALL": "Wszystkie",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "Ten portal",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Atores",
|
||||
"DIRECTOR": "Diretor",
|
||||
"SIMILAR": "Semelhantes",
|
||||
"CREW_JOB_DIRECTOR": "Diretor",
|
||||
"CREW_JOB_CREATOR": "Criador",
|
||||
"ACTOR_FILMOGRAPHY": "Filmografia",
|
||||
"ACTOR_FILTER_ALL": "Todos",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "Este portal",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Актеры",
|
||||
"DIRECTOR": "Режиссёр",
|
||||
"SIMILAR": "Похожее",
|
||||
"CREW_JOB_DIRECTOR": "Режиссёр",
|
||||
"CREW_JOB_CREATOR": "Создатель",
|
||||
"ACTOR_FILMOGRAPHY": "Фильмография",
|
||||
"ACTOR_FILTER_ALL": "Все",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "Этот портал",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "Oyuncular",
|
||||
"DIRECTOR": "Yönetmen",
|
||||
"SIMILAR": "Benzerler",
|
||||
"CREW_JOB_DIRECTOR": "Yönetmen",
|
||||
"CREW_JOB_CREATOR": "Yaratıcı",
|
||||
"ACTOR_FILMOGRAPHY": "Filmografi",
|
||||
"ACTOR_FILTER_ALL": "Tümü",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "Bu portal",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "演员",
|
||||
"DIRECTOR": "导演",
|
||||
"SIMILAR": "类似影片",
|
||||
"CREW_JOB_DIRECTOR": "导演",
|
||||
"CREW_JOB_CREATOR": "主创",
|
||||
"ACTOR_FILMOGRAPHY": "参演作品",
|
||||
"ACTOR_FILTER_ALL": "全部",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "当前门户",
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"ACTORS": "演員",
|
||||
"DIRECTOR": "導演",
|
||||
"SIMILAR": "類似作品",
|
||||
"CREW_JOB_DIRECTOR": "導演",
|
||||
"CREW_JOB_CREATOR": "主創",
|
||||
"ACTOR_FILMOGRAPHY": "參演作品",
|
||||
"ACTOR_FILTER_ALL": "全部",
|
||||
"ACTOR_SCOPE_THIS_PORTAL": "目前入口",
|
||||
|
||||
@@ -65,8 +65,11 @@ Integration glue per portal:
|
||||
|
||||
Components read the selection through signals and re-render when the merged
|
||||
item lands. Enriched cast (`tmdb_cast` with profile photos) renders as
|
||||
avatar chips in the detail views; a "check key" button in the settings
|
||||
section validates the API key against `/configuration`.
|
||||
avatar chips in the detail views, and so do directors/creators
|
||||
(`tmdb_directors`: movie directors from `credits.crew` with
|
||||
`job === 'Director'`, series creators from `created_by`) — both chip kinds
|
||||
carry `tmdbPersonId` and open the same person page. A "check key" button
|
||||
in the settings section validates the API key against `/configuration`.
|
||||
|
||||
## Match Confidence
|
||||
|
||||
@@ -187,7 +190,11 @@ Cast chips carry the TMDB person id (`tmdbPersonId` on
|
||||
current portal. The page loads `/person/{id}?append_to_response=
|
||||
combined_credits` via `TmdbEnrichmentService.getPersonDetails` (cached
|
||||
under `person:{id}` with media_type `person`) and renders the shared
|
||||
`ActorViewComponent` (`libs/ui/shared-portals`).
|
||||
`ActorViewComponent` (`libs/ui/shared-portals`). The filmography merges
|
||||
acting credits (`combined_credits.cast`) with directing/creating credits
|
||||
(`combined_credits.crew`, jobs `Director`/`Creator`) into one list —
|
||||
acting wins the per-title dedup, directing-only titles show the job in
|
||||
the character slot — so the page serves actors and directors alike.
|
||||
|
||||
Filmography has two scopes:
|
||||
|
||||
|
||||
@@ -245,6 +245,13 @@ describe('stalker-vod.utils regressions', () => {
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '8.1',
|
||||
tmdb_cast: tmdbCast,
|
||||
tmdb_directors: [
|
||||
{
|
||||
name: 'Eric Kripke',
|
||||
profileUrl: null,
|
||||
tmdbPersonId: 1216630,
|
||||
},
|
||||
],
|
||||
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/boys.jpg',
|
||||
tmdb_trailer: 'abc123def',
|
||||
tmdb_recommendations: tmdbRecommendations,
|
||||
@@ -252,6 +259,7 @@ describe('stalker-vod.utils regressions', () => {
|
||||
});
|
||||
|
||||
expect(info.tmdb_cast).toEqual(tmdbCast);
|
||||
expect(info.tmdb_directors?.[0]?.name).toBe('Eric Kripke');
|
||||
expect(info.tmdb_backdrop).toBe(
|
||||
'https://image.tmdb.org/t/p/w1280/boys.jpg'
|
||||
);
|
||||
|
||||
@@ -158,6 +158,9 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
|
||||
// views normalize the selected item on every render
|
||||
...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}),
|
||||
...(info.tmdb_cast ? { tmdb_cast: info.tmdb_cast } : {}),
|
||||
...(info.tmdb_directors
|
||||
? { tmdb_directors: info.tmdb_directors }
|
||||
: {}),
|
||||
...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}),
|
||||
...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}),
|
||||
...(info.tmdb_recommendations
|
||||
|
||||
+37
-6
@@ -37,7 +37,7 @@
|
||||
<div class="details__cast-strip">
|
||||
@for (
|
||||
member of serial.info.tmdb_cast;
|
||||
track member.name
|
||||
track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index
|
||||
) {
|
||||
<span
|
||||
class="details__cast-chip"
|
||||
@@ -78,12 +78,43 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (serial.info.director) {
|
||||
@if (serial.info.director || serial.info.tmdb_directors?.length) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.DIRECTOR' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ serial.info.director }}</span>
|
||||
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
|
||||
@if (serial.info.tmdb_directors?.length) {
|
||||
<div class="details__cast-strip">
|
||||
@for (member of serial.info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
|
||||
<span
|
||||
class="details__cast-chip"
|
||||
[class.details__cast-chip--clickable]="
|
||||
!!member.tmdbPersonId
|
||||
"
|
||||
(click)="openActor(member)"
|
||||
[title]="member.name"
|
||||
>
|
||||
@if (member.profileUrl) {
|
||||
<img
|
||||
class="details__cast-avatar"
|
||||
[src]="member.profileUrl"
|
||||
[alt]="member.name"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__cast-avatar details__cast-avatar--fallback"
|
||||
>
|
||||
{{ member.name.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__cast-name">{{
|
||||
member.name
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="value">{{ serial.info.director }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<span class="label">{{ 'XTREAM.CAST' | translate }}</span>
|
||||
@if (info.tmdb_cast?.length) {
|
||||
<div class="details__cast-strip">
|
||||
@for (member of info.tmdb_cast; track member.name) {
|
||||
@for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
|
||||
<span
|
||||
class="details__cast-chip"
|
||||
[class.details__cast-chip--clickable]="
|
||||
@@ -77,12 +77,43 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (info.director) {
|
||||
@if (info.director || info.tmdb_directors?.length) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.DIRECTOR' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ info.director }}</span>
|
||||
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
|
||||
@if (info.tmdb_directors?.length) {
|
||||
<div class="details__cast-strip">
|
||||
@for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
|
||||
<span
|
||||
class="details__cast-chip"
|
||||
[class.details__cast-chip--clickable]="
|
||||
!!member.tmdbPersonId
|
||||
"
|
||||
(click)="openActor(member)"
|
||||
[title]="member.name"
|
||||
>
|
||||
@if (member.profileUrl) {
|
||||
<img
|
||||
class="details__cast-avatar"
|
||||
[src]="member.profileUrl"
|
||||
[alt]="member.name"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__cast-avatar details__cast-avatar--fallback"
|
||||
>
|
||||
{{ member.name.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__cast-name">{{
|
||||
member.name
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="value">{{ info.director }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
@if (info.tmdb_cast?.length) {
|
||||
<div class="details__cast-strip">
|
||||
@for (member of info.tmdb_cast; track member.name) {
|
||||
@for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
|
||||
<span
|
||||
class="details__cast-chip"
|
||||
[class.details__cast-chip--clickable]="
|
||||
@@ -79,12 +79,43 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (info.director) {
|
||||
@if (info.director || info.tmdb_directors?.length) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.DIRECTOR' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ info.director }}</span>
|
||||
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
|
||||
@if (info.tmdb_directors?.length) {
|
||||
<div class="details__cast-strip">
|
||||
@for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
|
||||
<span
|
||||
class="details__cast-chip"
|
||||
[class.details__cast-chip--clickable]="
|
||||
!!member.tmdbPersonId
|
||||
"
|
||||
(click)="openActor(member)"
|
||||
[title]="member.name"
|
||||
>
|
||||
@if (member.profileUrl) {
|
||||
<img
|
||||
class="details__cast-avatar"
|
||||
[src]="member.profileUrl"
|
||||
[alt]="member.name"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__cast-avatar details__cast-avatar--fallback"
|
||||
>
|
||||
{{ member.name.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__cast-name">{{
|
||||
member.name
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="value">{{ info.director }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (info.rating_kinopoisk) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { StalkerVodInfo } from '@iptvnator/shared/interfaces';
|
||||
import { mergeStalkerInfoWithTmdb } from './tmdb-merge';
|
||||
import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types';
|
||||
|
||||
describe('mergeStalkerInfoWithTmdb', () => {
|
||||
const providerStalkerInfo: StalkerVodInfo = {
|
||||
movie_image: '',
|
||||
description: '',
|
||||
name: 'Ирония судьбы, или С лёгким паром!',
|
||||
o_name: undefined,
|
||||
actors: 'Провайдерский актёр',
|
||||
director: '',
|
||||
releasedate: '1976',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '8.1',
|
||||
};
|
||||
|
||||
const tmdbMovieRu: TmdbMovieDetails = {
|
||||
id: 20992,
|
||||
title: 'Ирония судьбы, или С лёгким паром!',
|
||||
overview: 'Описание из TMDB',
|
||||
genres: [{ id: 35, name: 'комедия' }],
|
||||
release_date: '1976-01-01',
|
||||
vote_average: 7.9,
|
||||
vote_count: 250,
|
||||
poster_path: '/irony-poster.jpg',
|
||||
backdrop_path: '/irony-backdrop.jpg',
|
||||
credits: {
|
||||
cast: [
|
||||
{
|
||||
name: 'Андрей Мягков',
|
||||
order: 0,
|
||||
profile_path: '/myagkov.jpg',
|
||||
},
|
||||
],
|
||||
crew: [{ id: 77, name: 'Эльдар Рязанов', job: 'Director' }],
|
||||
},
|
||||
};
|
||||
|
||||
it('prefers TMDB editorial fields and attaches cast/backdrop', () => {
|
||||
const merged = mergeStalkerInfoWithTmdb(
|
||||
providerStalkerInfo,
|
||||
tmdbMovieRu,
|
||||
'movie'
|
||||
);
|
||||
|
||||
expect(merged.description).toBe('Описание из TMDB');
|
||||
expect(merged.actors).toBe('Андрей Мягков');
|
||||
expect(merged.director).toBe('Эльдар Рязанов');
|
||||
expect(merged.tmdb_directors?.[0]).toEqual({
|
||||
name: 'Эльдар Рязанов',
|
||||
profileUrl: null,
|
||||
tmdbPersonId: 77,
|
||||
});
|
||||
expect(merged.genre).toBe('комедия');
|
||||
expect(merged.movie_image).toBe(
|
||||
'https://image.tmdb.org/t/p/w500/irony-poster.jpg'
|
||||
);
|
||||
expect(merged.tmdb_backdrop).toBe(
|
||||
'https://image.tmdb.org/t/p/w1280/irony-backdrop.jpg'
|
||||
);
|
||||
expect(merged.tmdb_cast).toEqual([
|
||||
{
|
||||
name: 'Андрей Мягков',
|
||||
profileUrl: 'https://image.tmdb.org/t/p/w185/myagkov.jpg',
|
||||
},
|
||||
]);
|
||||
// Provider keeps its own fields where TMDB should not win
|
||||
expect(merged.name).toBe('Ирония судьбы, или С лёгким паром!');
|
||||
expect(merged.rating_kinopoisk).toBe('8.1');
|
||||
expect(merged.releasedate).toBe('1976');
|
||||
});
|
||||
|
||||
it('only fills rating_imdb when the provider left it empty', () => {
|
||||
const merged = mergeStalkerInfoWithTmdb(
|
||||
providerStalkerInfo,
|
||||
tmdbMovieRu,
|
||||
'movie'
|
||||
);
|
||||
expect(merged.rating_imdb).toBe('7.9');
|
||||
|
||||
const withProviderRating = mergeStalkerInfoWithTmdb(
|
||||
{ ...providerStalkerInfo, rating_imdb: '8.4' },
|
||||
tmdbMovieRu,
|
||||
'movie'
|
||||
);
|
||||
expect(withProviderRating.rating_imdb).toBe('8.4');
|
||||
});
|
||||
|
||||
it('uses series creators as director for tv items', () => {
|
||||
const tv: TmdbTvDetails = {
|
||||
id: 1,
|
||||
overview: 'tv overview',
|
||||
created_by: [{ name: 'Создатель Сериала' }],
|
||||
};
|
||||
const merged = mergeStalkerInfoWithTmdb(providerStalkerInfo, tv, 'tv');
|
||||
expect(merged.director).toBe('Создатель Сериала');
|
||||
});
|
||||
|
||||
it('keeps provider values for missing TMDB fields', () => {
|
||||
const merged = mergeStalkerInfoWithTmdb(
|
||||
{ ...providerStalkerInfo, description: 'Провайдерский сюжет' },
|
||||
{ id: 20992 },
|
||||
'movie'
|
||||
);
|
||||
|
||||
expect(merged.description).toBe('Провайдерский сюжет');
|
||||
expect(merged.actors).toBe('Провайдерский актёр');
|
||||
expect(merged.tmdb_cast).toBeUndefined();
|
||||
expect(merged.tmdb_backdrop).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,5 @@
|
||||
import {
|
||||
StalkerVodInfo,
|
||||
XtreamSerieInfo,
|
||||
XtreamVodInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
mergeSerieInfoWithTmdb,
|
||||
mergeStalkerInfoWithTmdb,
|
||||
mergeVodInfoWithTmdb,
|
||||
} from './tmdb-merge';
|
||||
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 {
|
||||
@@ -68,8 +60,15 @@ const tmdbMovie: TmdbMovieDetails = {
|
||||
{ name: 'Laurence Fishburne', order: 1, profile_path: null },
|
||||
],
|
||||
crew: [
|
||||
{ name: 'Lana Wachowski', job: 'Director' },
|
||||
{ name: 'Lilly Wachowski', job: 'Director' },
|
||||
{
|
||||
id: 9339,
|
||||
name: 'Lana Wachowski',
|
||||
job: 'Director',
|
||||
profile_path: '/lana.jpg',
|
||||
},
|
||||
{ id: 9340, name: 'Lilly Wachowski', job: 'Director' },
|
||||
// Duplicate crew row TMDB sometimes returns — must be deduped
|
||||
{ id: 9339, name: 'Lana Wachowski', job: 'Director' },
|
||||
{ name: 'Someone Else', job: 'Producer' },
|
||||
],
|
||||
},
|
||||
@@ -106,7 +105,21 @@ describe('mergeVodInfoWithTmdb', () => {
|
||||
expect(merged.description).toBe('TMDB overview');
|
||||
expect(merged.cast).toBe('Keanu Reeves, Laurence Fishburne');
|
||||
expect(merged.actors).toBe('Keanu Reeves, Laurence Fishburne');
|
||||
expect(merged.director).toBe('Lana Wachowski, Lilly Wachowski');
|
||||
expect(merged.director).toBe(
|
||||
'Lana Wachowski, Lilly Wachowski, Lana Wachowski'
|
||||
);
|
||||
expect(merged.tmdb_directors).toEqual([
|
||||
{
|
||||
name: 'Lana Wachowski',
|
||||
profileUrl: 'https://image.tmdb.org/t/p/w185/lana.jpg',
|
||||
tmdbPersonId: 9339,
|
||||
},
|
||||
{
|
||||
name: 'Lilly Wachowski',
|
||||
profileUrl: null,
|
||||
tmdbPersonId: 9340,
|
||||
},
|
||||
]);
|
||||
expect(merged.genre).toBe('Action, Science Fiction');
|
||||
expect(merged.rating).toBe(8.2);
|
||||
expect(merged.tmdb_id).toBe(603);
|
||||
@@ -242,7 +255,10 @@ describe('mergeSerieInfoWithTmdb', () => {
|
||||
vote_count: 3200,
|
||||
poster_path: '/dark-poster.jpg',
|
||||
backdrop_path: '/dark-backdrop.jpg',
|
||||
created_by: [{ name: 'Baran bo Odar' }, { name: 'Jantje Friese' }],
|
||||
created_by: [
|
||||
{ id: 91, name: 'Baran bo Odar', profile_path: '/odar.jpg' },
|
||||
{ name: 'Jantje Friese' },
|
||||
],
|
||||
credits: {
|
||||
cast: [{ name: 'Louis Hofmann', order: 0 }],
|
||||
},
|
||||
@@ -254,6 +270,14 @@ describe('mergeSerieInfoWithTmdb', () => {
|
||||
expect(merged.plot).toBe('TMDB tv overview');
|
||||
expect(merged.cast).toBe('Louis Hofmann');
|
||||
expect(merged.director).toBe('Baran bo Odar, Jantje Friese');
|
||||
expect(merged.tmdb_directors).toEqual([
|
||||
{
|
||||
name: 'Baran bo Odar',
|
||||
profileUrl: 'https://image.tmdb.org/t/p/w185/odar.jpg',
|
||||
tmdbPersonId: 91,
|
||||
},
|
||||
{ name: 'Jantje Friese', profileUrl: null },
|
||||
]);
|
||||
expect(merged.genre).toBe('Mystery');
|
||||
expect(merged.rating).toBe('8.4');
|
||||
expect(merged.rating_5based).toBe(4.2);
|
||||
@@ -279,108 +303,3 @@ describe('mergeSerieInfoWithTmdb', () => {
|
||||
expect(merged.cover).toBe('http://provider/cover.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeStalkerInfoWithTmdb', () => {
|
||||
const providerStalkerInfo: StalkerVodInfo = {
|
||||
movie_image: '',
|
||||
description: '',
|
||||
name: 'Ирония судьбы, или С лёгким паром!',
|
||||
o_name: undefined,
|
||||
actors: 'Провайдерский актёр',
|
||||
director: '',
|
||||
releasedate: '1976',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '8.1',
|
||||
};
|
||||
|
||||
const tmdbMovieRu: TmdbMovieDetails = {
|
||||
id: 20992,
|
||||
title: 'Ирония судьбы, или С лёгким паром!',
|
||||
overview: 'Описание из TMDB',
|
||||
genres: [{ id: 35, name: 'комедия' }],
|
||||
release_date: '1976-01-01',
|
||||
vote_average: 7.9,
|
||||
vote_count: 250,
|
||||
poster_path: '/irony-poster.jpg',
|
||||
backdrop_path: '/irony-backdrop.jpg',
|
||||
credits: {
|
||||
cast: [
|
||||
{
|
||||
name: 'Андрей Мягков',
|
||||
order: 0,
|
||||
profile_path: '/myagkov.jpg',
|
||||
},
|
||||
],
|
||||
crew: [{ name: 'Эльдар Рязанов', job: 'Director' }],
|
||||
},
|
||||
};
|
||||
|
||||
it('prefers TMDB editorial fields and attaches cast/backdrop', () => {
|
||||
const merged = mergeStalkerInfoWithTmdb(
|
||||
providerStalkerInfo,
|
||||
tmdbMovieRu,
|
||||
'movie'
|
||||
);
|
||||
|
||||
expect(merged.description).toBe('Описание из TMDB');
|
||||
expect(merged.actors).toBe('Андрей Мягков');
|
||||
expect(merged.director).toBe('Эльдар Рязанов');
|
||||
expect(merged.genre).toBe('комедия');
|
||||
expect(merged.movie_image).toBe(
|
||||
'https://image.tmdb.org/t/p/w500/irony-poster.jpg'
|
||||
);
|
||||
expect(merged.tmdb_backdrop).toBe(
|
||||
'https://image.tmdb.org/t/p/w1280/irony-backdrop.jpg'
|
||||
);
|
||||
expect(merged.tmdb_cast).toEqual([
|
||||
{
|
||||
name: 'Андрей Мягков',
|
||||
profileUrl: 'https://image.tmdb.org/t/p/w185/myagkov.jpg',
|
||||
},
|
||||
]);
|
||||
// Provider keeps its own fields where TMDB should not win
|
||||
expect(merged.name).toBe('Ирония судьбы, или С лёгким паром!');
|
||||
expect(merged.rating_kinopoisk).toBe('8.1');
|
||||
expect(merged.releasedate).toBe('1976');
|
||||
});
|
||||
|
||||
it('only fills rating_imdb when the provider left it empty', () => {
|
||||
const merged = mergeStalkerInfoWithTmdb(
|
||||
providerStalkerInfo,
|
||||
tmdbMovieRu,
|
||||
'movie'
|
||||
);
|
||||
expect(merged.rating_imdb).toBe('7.9');
|
||||
|
||||
const withProviderRating = mergeStalkerInfoWithTmdb(
|
||||
{ ...providerStalkerInfo, rating_imdb: '8.4' },
|
||||
tmdbMovieRu,
|
||||
'movie'
|
||||
);
|
||||
expect(withProviderRating.rating_imdb).toBe('8.4');
|
||||
});
|
||||
|
||||
it('uses series creators as director for tv items', () => {
|
||||
const tv: TmdbTvDetails = {
|
||||
id: 1,
|
||||
overview: 'tv overview',
|
||||
created_by: [{ name: 'Создатель Сериала' }],
|
||||
};
|
||||
const merged = mergeStalkerInfoWithTmdb(providerStalkerInfo, tv, 'tv');
|
||||
expect(merged.director).toBe('Создатель Сериала');
|
||||
});
|
||||
|
||||
it('keeps provider values for missing TMDB fields', () => {
|
||||
const merged = mergeStalkerInfoWithTmdb(
|
||||
{ ...providerStalkerInfo, description: 'Провайдерский сюжет' },
|
||||
{ id: 20992 },
|
||||
'movie'
|
||||
);
|
||||
|
||||
expect(merged.description).toBe('Провайдерский сюжет');
|
||||
expect(merged.actors).toBe('Провайдерский актёр');
|
||||
expect(merged.tmdb_cast).toBeUndefined();
|
||||
expect(merged.tmdb_backdrop).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,46 @@ function creatorNames(details: TmdbTvDetails): string {
|
||||
.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 */
|
||||
@@ -130,6 +170,7 @@ export function mergeVodInfoWithTmdb(
|
||||
details: TmdbMovieDetails
|
||||
): XtreamVodInfo {
|
||||
const tmdbCast = enrichedCast(details.credits);
|
||||
const tmdbDirectors = enrichedDirectors(details.credits);
|
||||
const trailer = pickTrailerKey(details);
|
||||
const recommendations = recommendationList(details);
|
||||
const cast = castNames(details.credits);
|
||||
@@ -163,6 +204,7 @@ export function mergeVodInfoWithTmdb(
|
||||
backdrop_path: mergedBackdrops(details, info.backdrop_path),
|
||||
episode_run_time: info.episode_run_time || (details.runtime ?? 0),
|
||||
youtube_trailer: prefer(trailer, info.youtube_trailer),
|
||||
...(tmdbDirectors.length > 0 ? { tmdb_directors: tmdbDirectors } : {}),
|
||||
...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}),
|
||||
...(recommendations.length > 0
|
||||
? { tmdb_recommendations: recommendations }
|
||||
@@ -175,6 +217,7 @@ export function mergeSerieInfoWithTmdb(
|
||||
details: TmdbTvDetails
|
||||
): XtreamSerieInfo {
|
||||
const tmdbCast = enrichedCast(details.credits);
|
||||
const tmdbDirectors = enrichedCreators(details);
|
||||
const trailer = pickTrailerKey(details);
|
||||
const recommendations = recommendationList(details);
|
||||
const cast = castNames(details.credits);
|
||||
@@ -197,6 +240,7 @@ export function mergeSerieInfoWithTmdb(
|
||||
backdrop_path: mergedBackdrops(details, info.backdrop_path),
|
||||
youtube_trailer: prefer(trailer, info.youtube_trailer),
|
||||
tmdb_id: details.id,
|
||||
...(tmdbDirectors.length > 0 ? { tmdb_directors: tmdbDirectors } : {}),
|
||||
...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}),
|
||||
...(recommendations.length > 0
|
||||
? { tmdb_recommendations: recommendations }
|
||||
@@ -216,6 +260,10 @@ export function mergeStalkerInfoWithTmdb(
|
||||
mediaType: TmdbMediaType
|
||||
): StalkerVodInfo {
|
||||
const tmdbCast = enrichedCast(details.credits);
|
||||
const tmdbDirectors =
|
||||
mediaType === 'movie'
|
||||
? enrichedDirectors(details.credits)
|
||||
: enrichedCreators(details as TmdbTvDetails);
|
||||
const trailer = pickTrailerKey(details);
|
||||
const recommendations = recommendationList(details);
|
||||
const cast = castNames(details.credits);
|
||||
@@ -245,6 +293,7 @@ export function mergeStalkerInfoWithTmdb(
|
||||
tmdb_id: details.id,
|
||||
...(backdrop ? { tmdb_backdrop: backdrop } : {}),
|
||||
...(trailer ? { tmdb_trailer: trailer } : {}),
|
||||
...(tmdbDirectors.length > 0 ? { tmdb_directors: tmdbDirectors } : {}),
|
||||
...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}),
|
||||
...(recommendations.length > 0
|
||||
? { tmdb_recommendations: recommendations }
|
||||
|
||||
@@ -35,6 +35,30 @@ const person: TmdbPersonDetails = {
|
||||
{ id: 42, title: 'No media type' },
|
||||
{ id: 43, media_type: 'movie', title: ' ' },
|
||||
],
|
||||
crew: [
|
||||
{
|
||||
id: 16869,
|
||||
media_type: 'movie',
|
||||
title: 'Legends of the Fall',
|
||||
release_date: '1994-12-16',
|
||||
job: 'Producer',
|
||||
},
|
||||
{
|
||||
id: 1422,
|
||||
media_type: 'movie',
|
||||
title: 'The Departed',
|
||||
release_date: '2006-10-05',
|
||||
job: 'Director',
|
||||
},
|
||||
{
|
||||
// Directed a title he also starred in — acting credit wins
|
||||
id: 550,
|
||||
media_type: 'movie',
|
||||
title: 'Fight Club',
|
||||
release_date: '1999-10-15',
|
||||
job: 'Director',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -57,19 +81,47 @@ describe('mapPersonFilmography', () => {
|
||||
const credits = mapPersonFilmography(person);
|
||||
|
||||
expect(credits.map((credit) => credit.tmdbId)).toEqual([
|
||||
550, 1104, 999,
|
||||
1422, 550, 1104, 999,
|
||||
]);
|
||||
expect(credits[0]).toEqual({
|
||||
expect(credits[1]).toEqual({
|
||||
tmdbId: 550,
|
||||
mediaType: 'movie',
|
||||
title: 'Fight Club',
|
||||
year: 1999,
|
||||
posterUrl: 'https://image.tmdb.org/t/p/w500/fc.jpg',
|
||||
character: 'Tyler Durden',
|
||||
crewJob: null,
|
||||
});
|
||||
expect(credits[1].mediaType).toBe('tv');
|
||||
expect(credits[2].mediaType).toBe('tv');
|
||||
// Undated entries sort last
|
||||
expect(credits[2].year).toBeNull();
|
||||
expect(credits[3].year).toBeNull();
|
||||
});
|
||||
|
||||
it('includes directing credits from the crew, acting wins the dedup', () => {
|
||||
const credits = mapPersonFilmography(person);
|
||||
|
||||
// Directed-only title carries the role separately from character,
|
||||
// so the UI can render it through a translated label
|
||||
const departed = credits.find((credit) => credit.tmdbId === 1422);
|
||||
expect(departed).toEqual({
|
||||
tmdbId: 1422,
|
||||
mediaType: 'movie',
|
||||
title: 'The Departed',
|
||||
year: 2006,
|
||||
posterUrl: null,
|
||||
character: null,
|
||||
crewJob: 'Director',
|
||||
});
|
||||
|
||||
// Non-director crew jobs (Producer) are not part of the filmography
|
||||
expect(
|
||||
credits.find((credit) => credit.tmdbId === 16869)
|
||||
).toBeUndefined();
|
||||
|
||||
// Fight Club was acted AND directed — the acting credit wins
|
||||
const fightClub = credits.find((credit) => credit.tmdbId === 550);
|
||||
expect(fightClub?.character).toBe('Tyler Durden');
|
||||
expect(fightClub?.crewJob).toBeNull();
|
||||
});
|
||||
|
||||
it('handles a person without credits', () => {
|
||||
|
||||
@@ -17,16 +17,30 @@ export interface ActorProfile {
|
||||
photoUrl: string | null;
|
||||
}
|
||||
|
||||
export type ActorCrewJob = 'Director' | 'Creator';
|
||||
|
||||
export interface ActorFilmographyCredit {
|
||||
tmdbId: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
title: string;
|
||||
year: number | null;
|
||||
posterUrl: string | null;
|
||||
/** Character name for acting credits, null for crew-only credits */
|
||||
character: string | null;
|
||||
/**
|
||||
* Crew role for directing-only credits — kept separate from
|
||||
* `character` so the UI can render it through translated labels
|
||||
* instead of TMDB's raw English job string.
|
||||
*/
|
||||
crewJob: ActorCrewJob | null;
|
||||
}
|
||||
|
||||
const MAX_FILMOGRAPHY_CREDITS = 80;
|
||||
/** Crew jobs worth showing on the person page (directors, TV creators) */
|
||||
const FILMOGRAPHY_CREW_JOBS = new Set<string>([
|
||||
'Director',
|
||||
'Creator',
|
||||
] satisfies ActorCrewJob[]);
|
||||
|
||||
export function mapPersonProfile(person: TmdbPersonDetails): ActorProfile {
|
||||
return {
|
||||
@@ -41,8 +55,11 @@ export function mapPersonProfile(person: TmdbPersonDetails): ActorProfile {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicated acting credits, newest first (undated entries last),
|
||||
* capped at {@link MAX_FILMOGRAPHY_CREDITS}.
|
||||
* Deduplicated acting + directing credits in one merged list, newest
|
||||
* first (undated entries last), capped at {@link MAX_FILMOGRAPHY_CREDITS}.
|
||||
* Acting credits win the dedup so "Actor — Character" survives when a
|
||||
* person both starred in and directed the same title; directing-only
|
||||
* titles carry the crew role in `crewJob` for translated rendering.
|
||||
*/
|
||||
export function mapPersonFilmography(
|
||||
person: TmdbPersonDetails
|
||||
@@ -50,7 +67,14 @@ export function mapPersonFilmography(
|
||||
const seen = new Set<string>();
|
||||
const credits: ActorFilmographyCredit[] = [];
|
||||
|
||||
for (const credit of person.combined_credits?.cast ?? []) {
|
||||
const castCredits = (person.combined_credits?.cast ?? []).map(
|
||||
(credit) => ({ credit, crewJob: null as ActorCrewJob | null })
|
||||
);
|
||||
const crewCredits = (person.combined_credits?.crew ?? [])
|
||||
.filter((credit) => credit.job && FILMOGRAPHY_CREW_JOBS.has(credit.job))
|
||||
.map((credit) => ({ credit, crewJob: credit.job as ActorCrewJob }));
|
||||
|
||||
for (const { credit, crewJob } of [...castCredits, ...crewCredits]) {
|
||||
const mediaType =
|
||||
credit.media_type === 'movie' || credit.media_type === 'tv'
|
||||
? credit.media_type
|
||||
@@ -69,6 +93,7 @@ export function mapPersonFilmography(
|
||||
year: extractYear(credit.release_date ?? credit.first_air_date),
|
||||
posterUrl: tmdbPosterUrl(credit.poster_path),
|
||||
character: credit.character?.trim() || null,
|
||||
crewJob,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -45,9 +45,11 @@ export interface TmdbCastMember {
|
||||
}
|
||||
|
||||
export interface TmdbCrewMember {
|
||||
id?: number;
|
||||
name: string;
|
||||
job?: string;
|
||||
department?: string;
|
||||
profile_path?: string | null;
|
||||
}
|
||||
|
||||
export interface TmdbCredits {
|
||||
@@ -97,7 +99,11 @@ export interface TmdbTvDetails extends TmdbDetailsBase {
|
||||
original_name?: string;
|
||||
first_air_date?: string;
|
||||
episode_run_time?: number[];
|
||||
created_by?: { name: string }[];
|
||||
created_by?: {
|
||||
id?: number;
|
||||
name: string;
|
||||
profile_path?: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TmdbEpisode {
|
||||
@@ -118,7 +124,7 @@ export interface TmdbSeasonDetails {
|
||||
episodes?: TmdbEpisode[];
|
||||
}
|
||||
|
||||
/** One acting credit from /person/{id} combined_credits */
|
||||
/** One credit from /person/{id} combined_credits (cast or crew) */
|
||||
export interface TmdbPersonCredit {
|
||||
id: number;
|
||||
media_type?: string;
|
||||
@@ -128,7 +134,11 @@ export interface TmdbPersonCredit {
|
||||
/** TV credits */
|
||||
name?: string;
|
||||
first_air_date?: string;
|
||||
/** Acting credits (cast array) */
|
||||
character?: string;
|
||||
/** Crew credits (crew array) — e.g. "Director" / "Directing" */
|
||||
job?: string;
|
||||
department?: string;
|
||||
poster_path?: string | null;
|
||||
vote_count?: number;
|
||||
popularity?: number;
|
||||
@@ -142,7 +152,10 @@ export interface TmdbPersonDetails {
|
||||
deathday?: string | null;
|
||||
place_of_birth?: string | null;
|
||||
profile_path?: string | null;
|
||||
combined_credits?: { cast?: TmdbPersonCredit[] };
|
||||
combined_credits?: {
|
||||
cast?: TmdbPersonCredit[];
|
||||
crew?: TmdbPersonCredit[];
|
||||
};
|
||||
}
|
||||
|
||||
export type TmdbDetails = TmdbMovieDetails | TmdbTvDetails;
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface StalkerVodInfo {
|
||||
rating_kinopoisk: string;
|
||||
/** Populated by TMDB enrichment; absent in raw portal responses */
|
||||
tmdb_cast?: TmdbEnrichedCastMember[];
|
||||
/** Directors (movies) / creators (series) as clickable person chips */
|
||||
tmdb_directors?: TmdbEnrichedCastMember[];
|
||||
/** TMDB backdrop URL — Stalker portals never provide one themselves */
|
||||
tmdb_backdrop?: string;
|
||||
/** YouTube trailer key from TMDB — Stalker portals provide no trailers */
|
||||
|
||||
@@ -30,6 +30,7 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
|
||||
ratingKinopoisk: info?.rating_kinopoisk,
|
||||
youtubeTrailer: info?.youtube_trailer,
|
||||
tmdbCast: info?.tmdb_cast,
|
||||
tmdbDirectors: info?.tmdb_directors,
|
||||
tmdbRecommendations: info?.tmdb_recommendations,
|
||||
};
|
||||
}
|
||||
@@ -59,6 +60,7 @@ export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta
|
||||
// Stalker portals provide no trailers; TMDB enrichment can
|
||||
youtubeTrailer: info?.tmdb_trailer,
|
||||
tmdbCast: info?.tmdb_cast,
|
||||
tmdbDirectors: info?.tmdb_directors,
|
||||
tmdbRecommendations: info?.tmdb_recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ export interface NormalizedVodMeta {
|
||||
youtubeTrailer?: string;
|
||||
/** Cast with profile photos, populated by TMDB enrichment */
|
||||
tmdbCast?: TmdbEnrichedCastMember[];
|
||||
/** Directors (movies) / creators (series) as clickable person chips */
|
||||
tmdbDirectors?: TmdbEnrichedCastMember[];
|
||||
/** TMDB recommendations (drives the cross-portal "Similar" rail) */
|
||||
tmdbRecommendations?: TmdbRecommendation[];
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface XtreamSerieInfo {
|
||||
category_id: string;
|
||||
/** Populated by TMDB enrichment; absent in raw provider responses */
|
||||
tmdb_cast?: TmdbEnrichedCastMember[];
|
||||
/** Directors (movies) / creators (series) as clickable person chips */
|
||||
tmdb_directors?: TmdbEnrichedCastMember[];
|
||||
/** Populated by TMDB enrichment; matched against the catalog in views */
|
||||
tmdb_recommendations?: TmdbRecommendation[];
|
||||
/** Matched TMDB show id — enables lazy season/episode enrichment */
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface XtreamVodInfo {
|
||||
rating_imdb?: string;
|
||||
/** Populated by TMDB enrichment; absent in raw provider responses */
|
||||
tmdb_cast?: TmdbEnrichedCastMember[];
|
||||
/** Directors (movies) / creators (series) as clickable person chips */
|
||||
tmdb_directors?: TmdbEnrichedCastMember[];
|
||||
/** Populated by TMDB enrichment; matched against the catalog in views */
|
||||
tmdb_recommendations?: TmdbRecommendation[];
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
@if (meta.tmdbCast?.length) {
|
||||
<div class="details__cast-strip">
|
||||
@for (member of meta.tmdbCast; track member.name) {
|
||||
@for (member of meta.tmdbCast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
|
||||
<span
|
||||
class="details__cast-chip"
|
||||
[class.details__cast-chip--clickable]="
|
||||
@@ -75,10 +75,43 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (meta.director) {
|
||||
@if (meta.director || meta.tmdbDirectors?.length) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
|
||||
<span class="value">{{ meta.director }}</span>
|
||||
@if (meta.tmdbDirectors?.length) {
|
||||
<div class="details__cast-strip">
|
||||
@for (member of meta.tmdbDirectors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
|
||||
<span
|
||||
class="details__cast-chip"
|
||||
[class.details__cast-chip--clickable]="
|
||||
!!member.tmdbPersonId
|
||||
"
|
||||
(click)="openActor(member)"
|
||||
[title]="member.name"
|
||||
>
|
||||
@if (member.profileUrl) {
|
||||
<img
|
||||
class="details__cast-avatar"
|
||||
[src]="member.profileUrl"
|
||||
[alt]="member.name"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__cast-avatar details__cast-avatar--fallback"
|
||||
>
|
||||
{{ member.name.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__cast-name">{{
|
||||
member.name
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="value">{{ meta.director }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (meta.ratingKinopoisk) {
|
||||
|
||||
@@ -165,6 +165,8 @@
|
||||
}
|
||||
@if (item.character) {
|
||||
· {{ item.character }}
|
||||
} @else if (item.crewJob) {
|
||||
· {{ crewJobKey(item.crewJob) | translate }}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -52,6 +52,13 @@ export class ActorViewComponent {
|
||||
|
||||
readonly filterMode = signal<'all' | 'available'>('all');
|
||||
|
||||
/** Translated label key for a crew-only credit ("Director"/"Creator") */
|
||||
crewJobKey(job: 'Director' | 'Creator'): string {
|
||||
return job === 'Creator'
|
||||
? 'XTREAM.CREW_JOB_CREATOR'
|
||||
: 'XTREAM.CREW_JOB_DIRECTOR';
|
||||
}
|
||||
|
||||
readonly visibleItems = computed(() =>
|
||||
this.showAvailabilityFilter() && this.filterMode() === 'available'
|
||||
? this.items().filter((item) => item.available)
|
||||
|
||||
Reference in new issue
Block a user