From d79c438aa1106c6cda0df9280d309d585906b8cf Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 24 Jul 2026 08:04:52 +0200 Subject: [PATCH] feat(tmdb): clickable director/creator chips and directing credits on person pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 4 ++ CLAUDE.md | 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 | 41 ++++++++++++-- .../serial-details.component.html | 41 ++++++++++++-- .../vod-details-route.component.html | 41 ++++++++++++-- libs/services/src/lib/tmdb/tmdb-merge.spec.ts | 47 ++++++++++++++-- libs/services/src/lib/tmdb/tmdb-merge.ts | 49 ++++++++++++++++ .../services/src/lib/tmdb/tmdb-person.spec.ts | 56 +++++++++++++++++-- libs/services/src/lib/tmdb/tmdb-person.ts | 19 +++++-- 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 | 37 +++++++++++- 19 files changed, 353 insertions(+), 37 deletions(-) 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 d8c1f2c9a..99fdd099d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -809,7 +809,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/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index cac28e6ba..b18937833 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..95ff1df99 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 @@ -78,12 +78,43 @@ } } - @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.name) { + + @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..856c9e846 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 @@ -77,12 +77,43 @@ } } - @if (info.director) { + @if (info.director || info.tmdb_directors?.length) {
- {{ - 'XTREAM.DIRECTOR' | translate - }} - {{ info.director }} + {{ 'XTREAM.DIRECTOR' | translate }} + @if (info.tmdb_directors?.length) { +
+ @for (member of info.tmdb_directors; track member.name) { + + @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..e2c588647 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 @@ -79,12 +79,43 @@ } } - @if (info.director) { + @if (info.director || info.tmdb_directors?.length) {
- {{ - 'XTREAM.DIRECTOR' | translate - }} - {{ info.director }} + {{ 'XTREAM.DIRECTOR' | translate }} + @if (info.tmdb_directors?.length) { +
+ @for (member of info.tmdb_directors; track member.name) { + + @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.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts index 1c8efd1c4..4da92f869 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts @@ -68,8 +68,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 +113,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 +263,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 +278,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); @@ -312,7 +344,7 @@ describe('mergeStalkerInfoWithTmdb', () => { profile_path: '/myagkov.jpg', }, ], - crew: [{ name: 'Эльдар Рязанов', job: 'Director' }], + crew: [{ id: 77, name: 'Эльдар Рязанов', job: 'Director' }], }, }; @@ -326,6 +358,11 @@ describe('mergeStalkerInfoWithTmdb', () => { 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' 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..e31b9e2d2 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,9 +81,9 @@ 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', @@ -67,9 +91,33 @@ describe('mapPersonFilmography', () => { posterUrl: 'https://image.tmdb.org/t/p/w500/fc.jpg', character: 'Tyler Durden', }); - 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 appears with the job in the character slot + const departed = credits.find((credit) => credit.tmdbId === 1422); + expect(departed).toEqual({ + tmdbId: 1422, + mediaType: 'movie', + title: 'The Departed', + year: 2006, + posterUrl: null, + character: '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'); }); 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..65fba1cd0 100644 --- a/libs/services/src/lib/tmdb/tmdb-person.ts +++ b/libs/services/src/lib/tmdb/tmdb-person.ts @@ -23,10 +23,13 @@ export interface ActorFilmographyCredit { title: string; year: number | null; posterUrl: string | null; + /** Character (acting credits) or job like "Director" (crew credits) */ character: string | 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']); export function mapPersonProfile(person: TmdbPersonDetails): ActorProfile { return { @@ -41,8 +44,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 show the job ("Director") in the character slot. */ export function mapPersonFilmography( person: TmdbPersonDetails @@ -50,7 +56,12 @@ export function mapPersonFilmography( const seen = new Set(); const credits: ActorFilmographyCredit[] = []; - for (const credit of person.combined_credits?.cast ?? []) { + const castCredits = person.combined_credits?.cast ?? []; + const crewCredits = (person.combined_credits?.crew ?? []).filter( + (credit) => credit.job && FILMOGRAPHY_CREW_JOBS.has(credit.job) + ); + + for (const credit of [...castCredits, ...crewCredits]) { const mediaType = credit.media_type === 'movie' || credit.media_type === 'tv' ? credit.media_type @@ -68,7 +79,7 @@ export function mapPersonFilmography( title, year: extractYear(credit.release_date ?? credit.first_air_date), posterUrl: tmdbPosterUrl(credit.poster_path), - character: credit.character?.trim() || null, + character: credit.character?.trim() || credit.job?.trim() || null, }); } 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..588f5a350 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 @@ -75,10 +75,43 @@ } } - @if (meta.director) { + @if (meta.director || meta.tmdbDirectors?.length) {
{{ 'XTREAM.DIRECTOR' | translate }} - {{ meta.director }} + @if (meta.tmdbDirectors?.length) { +
+ @for (member of meta.tmdbDirectors; track member.name) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ meta.director }} + }
} @if (meta.ratingKinopoisk) {