From 5aa44d19d4faa444e04a43fff12c814b902fecff Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:42:41 +0200 Subject: [PATCH] feat(tmdb): clickable director/creator chips and directing credits on person pages (#1227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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' / 'i') 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 --------- Co-authored-by: Claude Opus 4.8 --- .gitignore | 4 + CLAUDE.md | 2 +- apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/tmdb-metadata-enrichment.md | 13 +- .../src/lib/stalker-vod.utils.spec.ts | 8 + .../data-access/src/lib/stalker-vod.utils.ts | 3 + .../stalker-series-view.component.html | 43 ++++- .../serial-details.component.html | 43 ++++- .../vod-details-route.component.html | 43 ++++- .../src/lib/tmdb/tmdb-merge-stalker.spec.ts | 113 +++++++++++++ libs/services/src/lib/tmdb/tmdb-merge.spec.ts | 157 +++++------------- libs/services/src/lib/tmdb/tmdb-merge.ts | 49 ++++++ .../services/src/lib/tmdb/tmdb-person.spec.ts | 60 ++++++- libs/services/src/lib/tmdb/tmdb-person.ts | 31 +++- libs/services/src/lib/tmdb/tmdb.types.ts | 19 ++- .../src/lib/stalker-vod-details.interface.ts | 2 + .../src/lib/vod-details-adapters.ts | 2 + .../src/lib/vod-details-item.interface.ts | 2 + .../src/lib/xtream-serie-details.interface.ts | 2 + .../src/lib/xtream-vod-details.interface.ts | 2 + .../vod-details/vod-details.component.html | 39 ++++- .../lib/actor-view/actor-view.component.html | 2 + .../lib/actor-view/actor-view.component.ts | 7 + 40 files changed, 528 insertions(+), 154 deletions(-) create mode 100644 libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts diff --git a/.gitignore b/.gitignore index c2263e287..eabdfdcd4 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index b5d2fedc3..04e4c99a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 7c9cde711..ed10b1fad 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -785,6 +785,8 @@ "ACTORS": "الممثلون", "DIRECTOR": "المخرج", "SIMILAR": "مشابه", + "CREW_JOB_DIRECTOR": "المخرج", + "CREW_JOB_CREATOR": "المبتكر", "ACTOR_FILMOGRAPHY": "الأعمال الفنية", "ACTOR_FILTER_ALL": "الكل", "ACTOR_SCOPE_THIS_PORTAL": "هذه البوابة", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index f83fe2f02..e0d58c5b7 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -785,6 +785,8 @@ "ACTORS": "الممثلون", "DIRECTOR": "المخرج", "SIMILAR": "بحال هادو", + "CREW_JOB_DIRECTOR": "المخرج", + "CREW_JOB_CREATOR": "المبتكر", "ACTOR_FILMOGRAPHY": "الأفلام ديالو", "ACTOR_FILTER_ALL": "كلشي", "ACTOR_SCOPE_THIS_PORTAL": "هاد البوابة", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index bfc239cc5..485e0f796 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -785,6 +785,8 @@ "ACTORS": "Акцёры", "DIRECTOR": "Рэжысёр", "SIMILAR": "Падобнае", + "CREW_JOB_DIRECTOR": "Рэжысёр", + "CREW_JOB_CREATOR": "Стваральнік", "ACTOR_FILMOGRAPHY": "Фільмаграфія", "ACTOR_FILTER_ALL": "Усе", "ACTOR_SCOPE_THIS_PORTAL": "Гэты партал", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 8b3a4b2cc..ee8c4cbb6 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 414ea0d4b..92828089d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -785,6 +785,8 @@ "ACTORS": "Ηθοποιοί", "DIRECTOR": "Σκηνοθέτης", "SIMILAR": "Παρόμοια", + "CREW_JOB_DIRECTOR": "Σκηνοθέτης", + "CREW_JOB_CREATOR": "Δημιουργός", "ACTOR_FILMOGRAPHY": "Φιλμογραφία", "ACTOR_FILTER_ALL": "Όλα", "ACTOR_SCOPE_THIS_PORTAL": "Αυτή η πύλη", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index aca191d45..c88c47858 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 81b329133..7cdbc4bde 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index eb5e74cb1..569cb0a9e 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 5e790f94b..c16dcf61e 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 179db53b3..1fe40c681 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -785,6 +785,8 @@ "ACTORS": "出演者", "DIRECTOR": "監督", "SIMILAR": "類似作品", + "CREW_JOB_DIRECTOR": "監督", + "CREW_JOB_CREATOR": "クリエイター", "ACTOR_FILMOGRAPHY": "出演作品", "ACTOR_FILTER_ALL": "すべて", "ACTOR_SCOPE_THIS_PORTAL": "このポータル", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index f861aab78..d64b729cd 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -785,6 +785,8 @@ "ACTORS": "배우", "DIRECTOR": "감독", "SIMILAR": "비슷한 콘텐츠", + "CREW_JOB_DIRECTOR": "감독", + "CREW_JOB_CREATOR": "크리에이터", "ACTOR_FILMOGRAPHY": "출연 작품", "ACTOR_FILTER_ALL": "전체", "ACTOR_SCOPE_THIS_PORTAL": "현재 포털", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 15b75ce94..dd5af527d 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index e629135ed..bffa8dc82 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 8572320aa..360be5be3 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index dc3377518..02d4026a7 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -785,6 +785,8 @@ "ACTORS": "Актеры", "DIRECTOR": "Режиссёр", "SIMILAR": "Похожее", + "CREW_JOB_DIRECTOR": "Режиссёр", + "CREW_JOB_CREATOR": "Создатель", "ACTOR_FILMOGRAPHY": "Фильмография", "ACTOR_FILTER_ALL": "Все", "ACTOR_SCOPE_THIS_PORTAL": "Этот портал", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index ac4a86246..f699a9cf5 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index d40d29f9a..4a6cf0e4a 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -785,6 +785,8 @@ "ACTORS": "演员", "DIRECTOR": "导演", "SIMILAR": "类似影片", + "CREW_JOB_DIRECTOR": "导演", + "CREW_JOB_CREATOR": "主创", "ACTOR_FILMOGRAPHY": "参演作品", "ACTOR_FILTER_ALL": "全部", "ACTOR_SCOPE_THIS_PORTAL": "当前门户", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 2e048b4dd..7bf0abebe 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -785,6 +785,8 @@ "ACTORS": "演員", "DIRECTOR": "導演", "SIMILAR": "類似作品", + "CREW_JOB_DIRECTOR": "導演", + "CREW_JOB_CREATOR": "主創", "ACTOR_FILMOGRAPHY": "參演作品", "ACTOR_FILTER_ALL": "全部", "ACTOR_SCOPE_THIS_PORTAL": "目前入口", diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index dfe689ef7..08fd79010 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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: diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index 9b2cc4c99..c37614014 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -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' ); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index 987cf755c..44c7c4d26 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -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 diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 861be1769..af79c3b2f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -37,7 +37,7 @@
@for ( member of serial.info.tmdb_cast; - track member.name + track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index ) { } - @if (serial.info.director) { + @if (serial.info.director || serial.info.tmdb_directors?.length) {
- {{ - 'XTREAM.DIRECTOR' | translate - }} - {{ serial.info.director }} + {{ 'XTREAM.DIRECTOR' | translate }} + @if (serial.info.tmdb_directors?.length) { +
+ @for (member of serial.info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ serial.info.director }} + }
} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index f10e52862..b7f715a8a 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -37,7 +37,7 @@ {{ 'XTREAM.CAST' | translate }} @if (info.tmdb_cast?.length) {
- @for (member of info.tmdb_cast; track member.name) { + @for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { - {{ - 'XTREAM.DIRECTOR' | translate - }} - {{ info.director }} + {{ 'XTREAM.DIRECTOR' | translate }} + @if (info.tmdb_directors?.length) { +
+ @for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ info.director }} + }
} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 1a5494699..b8b3e79d6 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -37,7 +37,7 @@ {{ 'XTREAM.ACTORS' | translate }} @if (info.tmdb_cast?.length) {
- @for (member of info.tmdb_cast; track member.name) { + @for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { - {{ - 'XTREAM.DIRECTOR' | translate - }} - {{ info.director }} + {{ 'XTREAM.DIRECTOR' | translate }} + @if (info.tmdb_directors?.length) { +
+ @for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ info.director }} + }
} @if (info.rating_kinopoisk) { diff --git a/libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts new file mode 100644 index 000000000..61b6b3846 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts @@ -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(); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts index 1c8efd1c4..050d13775 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts @@ -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 { @@ -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(); - }); -}); diff --git a/libs/services/src/lib/tmdb/tmdb-merge.ts b/libs/services/src/lib/tmdb/tmdb-merge.ts index f583d6482..c4e0a2cca 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.ts @@ -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(); + 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 } diff --git a/libs/services/src/lib/tmdb/tmdb-person.spec.ts b/libs/services/src/lib/tmdb/tmdb-person.spec.ts index 117b19d52..94e9c0404 100644 --- a/libs/services/src/lib/tmdb/tmdb-person.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-person.spec.ts @@ -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', () => { diff --git a/libs/services/src/lib/tmdb/tmdb-person.ts b/libs/services/src/lib/tmdb/tmdb-person.ts index e3b64855b..13da913ce 100644 --- a/libs/services/src/lib/tmdb/tmdb-person.ts +++ b/libs/services/src/lib/tmdb/tmdb-person.ts @@ -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([ + '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(); 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, }); } diff --git a/libs/services/src/lib/tmdb/tmdb.types.ts b/libs/services/src/lib/tmdb/tmdb.types.ts index 87565fa44..f36ee6c06 100644 --- a/libs/services/src/lib/tmdb/tmdb.types.ts +++ b/libs/services/src/lib/tmdb/tmdb.types.ts @@ -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; diff --git a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts index 35f65278a..98e2d1458 100644 --- a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts @@ -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 */ diff --git a/libs/shared/interfaces/src/lib/vod-details-adapters.ts b/libs/shared/interfaces/src/lib/vod-details-adapters.ts index ff1520a72..608b3b772 100644 --- a/libs/shared/interfaces/src/lib/vod-details-adapters.ts +++ b/libs/shared/interfaces/src/lib/vod-details-adapters.ts @@ -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, }; } diff --git a/libs/shared/interfaces/src/lib/vod-details-item.interface.ts b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts index d66bfab8f..1bb8b607a 100644 --- a/libs/shared/interfaces/src/lib/vod-details-item.interface.ts +++ b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts @@ -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[]; } diff --git a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts index c82a7f138..b5a047c7c 100644 --- a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts @@ -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 */ diff --git a/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts b/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts index d9b47f51f..10fbf2e1f 100644 --- a/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts @@ -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[]; } diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index 59730c10b..7e578926c 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -37,7 +37,7 @@ {{ 'XTREAM.ACTORS' | translate }} @if (meta.tmdbCast?.length) {
- @for (member of meta.tmdbCast; track member.name) { + @for (member of meta.tmdbCast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { {{ 'XTREAM.DIRECTOR' | translate }} - {{ meta.director }} + @if (meta.tmdbDirectors?.length) { +
+ @for (member of meta.tmdbDirectors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ meta.director }} + }
} @if (meta.ratingKinopoisk) { diff --git a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html index 69bfefd90..e9aeecc34 100644 --- a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html +++ b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html @@ -165,6 +165,8 @@ } @if (item.character) { · {{ item.character }} + } @else if (item.crewJob) { + · {{ crewJobKey(item.crewJob) | translate }} }
diff --git a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts index 0d55cb647..0c6b993b7 100644 --- a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts +++ b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts @@ -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)