diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json
index 833bd2075..6010740a3 100644
--- a/apps/web/src/assets/i18n/ar.json
+++ b/apps/web/src/assets/i18n/ar.json
@@ -783,6 +783,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 27188b4e8..ee374f10d 100644
--- a/apps/web/src/assets/i18n/ary.json
+++ b/apps/web/src/assets/i18n/ary.json
@@ -783,6 +783,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 ac6cd8081..38b110ac9 100644
--- a/apps/web/src/assets/i18n/by.json
+++ b/apps/web/src/assets/i18n/by.json
@@ -783,6 +783,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 b9a3c2d9f..d9cfaa05f 100644
--- a/apps/web/src/assets/i18n/de.json
+++ b/apps/web/src/assets/i18n/de.json
@@ -783,6 +783,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 335cc47bb..25d8a343f 100644
--- a/apps/web/src/assets/i18n/el.json
+++ b/apps/web/src/assets/i18n/el.json
@@ -783,6 +783,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 92d396baa..0912214c2 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -783,6 +783,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 c7cef836c..f8bd90d41 100644
--- a/apps/web/src/assets/i18n/es.json
+++ b/apps/web/src/assets/i18n/es.json
@@ -783,6 +783,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 79e523fff..ba869a727 100644
--- a/apps/web/src/assets/i18n/fr.json
+++ b/apps/web/src/assets/i18n/fr.json
@@ -783,6 +783,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 0421f61d8..bd9aefbf1 100644
--- a/apps/web/src/assets/i18n/it.json
+++ b/apps/web/src/assets/i18n/it.json
@@ -783,6 +783,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 73ea0138e..7361326f3 100644
--- a/apps/web/src/assets/i18n/ja.json
+++ b/apps/web/src/assets/i18n/ja.json
@@ -783,6 +783,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 e43ddc6b3..d77cca393 100644
--- a/apps/web/src/assets/i18n/ko.json
+++ b/apps/web/src/assets/i18n/ko.json
@@ -783,6 +783,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 6d05aab79..90db26c23 100644
--- a/apps/web/src/assets/i18n/nl.json
+++ b/apps/web/src/assets/i18n/nl.json
@@ -783,6 +783,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 14758b632..14851a2cb 100644
--- a/apps/web/src/assets/i18n/pl.json
+++ b/apps/web/src/assets/i18n/pl.json
@@ -783,6 +783,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 89f58e2ec..541edfdb4 100644
--- a/apps/web/src/assets/i18n/pt.json
+++ b/apps/web/src/assets/i18n/pt.json
@@ -783,6 +783,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 f0c007b9d..1cdc23312 100644
--- a/apps/web/src/assets/i18n/ru.json
+++ b/apps/web/src/assets/i18n/ru.json
@@ -783,6 +783,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 d89dbad45..8f2f81762 100644
--- a/apps/web/src/assets/i18n/tr.json
+++ b/apps/web/src/assets/i18n/tr.json
@@ -783,6 +783,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 17ccb0b36..26afe5757 100644
--- a/apps/web/src/assets/i18n/zh.json
+++ b/apps/web/src/assets/i18n/zh.json
@@ -783,6 +783,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 87d93aa43..4490ed506 100644
--- a/apps/web/src/assets/i18n/zhtw.json
+++ b/apps/web/src/assets/i18n/zhtw.json
@@ -783,6 +783,8 @@
"ACTORS": "演員",
"DIRECTOR": "導演",
"SIMILAR": "類似作品",
+ "CREW_JOB_DIRECTOR": "導演",
+ "CREW_JOB_CREATOR": "主創",
"ACTOR_FILMOGRAPHY": "參演作品",
"ACTOR_FILTER_ALL": "全部",
"ACTOR_SCOPE_THIS_PORTAL": "目前入口",
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 95ff1df99..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
) {
{{ 'XTREAM.DIRECTOR' | translate }}
@if (serial.info.tmdb_directors?.length) {
- @for (member of serial.info.tmdb_directors; track member.name) {
+ @for (member of serial.info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
{{ '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 }}
@if (info.tmdb_directors?.length) {
- @for (member of info.tmdb_directors; track member.name) {
+ @for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
{{ '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 }}
@if (info.tmdb_directors?.length) {
- @for (member of info.tmdb_directors; track member.name) {
+ @for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
{
+ 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 4da92f869..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 {
@@ -311,113 +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: [{ 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-person.spec.ts b/libs/services/src/lib/tmdb/tmdb-person.spec.ts
index e31b9e2d2..94e9c0404 100644
--- a/libs/services/src/lib/tmdb/tmdb-person.spec.ts
+++ b/libs/services/src/lib/tmdb/tmdb-person.spec.ts
@@ -90,6 +90,7 @@ describe('mapPersonFilmography', () => {
year: 1999,
posterUrl: 'https://image.tmdb.org/t/p/w500/fc.jpg',
character: 'Tyler Durden',
+ crewJob: null,
});
expect(credits[2].mediaType).toBe('tv');
// Undated entries sort last
@@ -99,7 +100,8 @@ describe('mapPersonFilmography', () => {
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
+ // 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,
@@ -107,7 +109,8 @@ describe('mapPersonFilmography', () => {
title: 'The Departed',
year: 2006,
posterUrl: null,
- character: 'Director',
+ character: null,
+ crewJob: 'Director',
});
// Non-director crew jobs (Producer) are not part of the filmography
@@ -118,6 +121,7 @@ describe('mapPersonFilmography', () => {
// 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 65fba1cd0..13da913ce 100644
--- a/libs/services/src/lib/tmdb/tmdb-person.ts
+++ b/libs/services/src/lib/tmdb/tmdb-person.ts
@@ -17,19 +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 (acting credits) or job like "Director" (crew credits) */
+ /** 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']);
+const FILMOGRAPHY_CREW_JOBS = new Set([
+ 'Director',
+ 'Creator',
+] satisfies ActorCrewJob[]);
export function mapPersonProfile(person: TmdbPersonDetails): ActorProfile {
return {
@@ -48,7 +59,7 @@ export function mapPersonProfile(person: TmdbPersonDetails): ActorProfile {
* 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.
+ * titles carry the crew role in `crewJob` for translated rendering.
*/
export function mapPersonFilmography(
person: TmdbPersonDetails
@@ -56,12 +67,14 @@ export function mapPersonFilmography(
const seen = new Set();
const credits: ActorFilmographyCredit[] = [];
- const castCredits = person.combined_credits?.cast ?? [];
- const crewCredits = (person.combined_credits?.crew ?? []).filter(
- (credit) => credit.job && FILMOGRAPHY_CREW_JOBS.has(credit.job)
+ 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 of [...castCredits, ...crewCredits]) {
+ for (const { credit, crewJob } of [...castCredits, ...crewCredits]) {
const mediaType =
credit.media_type === 'movie' || credit.media_type === 'tv'
? credit.media_type
@@ -79,7 +92,8 @@ export function mapPersonFilmography(
title,
year: extractYear(credit.release_date ?? credit.first_air_date),
posterUrl: tmdbPosterUrl(credit.poster_path),
- character: credit.character?.trim() || credit.job?.trim() || null,
+ character: credit.character?.trim() || null,
+ crewJob,
});
}
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 588f5a350..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 }}
@if (meta.tmdbDirectors?.length) {
- @for (member of meta.tmdbDirectors; track member.name) {
+ @for (member of meta.tmdbDirectors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) {
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)