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) {
}
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) {
}
- @if (info.director) {
+ @if (info.director || info.tmdb_directors?.length) {
}
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) {
}
- @if (info.director) {
+ @if (info.director || info.tmdb_directors?.length) {
}
@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) {
}
- @if (meta.director) {
+ @if (meta.director || meta.tmdbDirectors?.length) {
}
@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)