feat(tmdb): series production-status chip and person death dates (#1240)

Two fields TMDB already sends us and the merge threw away — no new API
calls, no cache-key bump, they light up on existing cached payloads.

Series detail views (Xtream and Stalker) gain a production-status chip:
"Ended" tells you a show is finished before you commit to it, "Returning"
that it is not. TMDB returns `status` as an ENGLISH string even under
language=ru-RU, so it is normalized to a stable token
(normalizeSeriesStatus) and rendered through translated labels
(seriesStatusLabelKey). Unknown values are dropped rather than shown, so
a status TMDB adds later can never leak raw English into 19 locales.

Person pages render `deathday`, which mapPersonProfile has always parsed
into ActorProfile and no template ever read.

i18n: 7 keys across all 19 locales via the tools/i18n workflow.
Tests: status normalization (token mapping, case-insensitivity, the
British "cancelled" spelling, unknown/missing dropped).
Docs: tmdb-metadata-enrichment.md, CLAUDE.md.

Refs docs/architecture/tmdb-roadmap.md C1 and the zero-extra-call tier.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.8 authored and GitHub committed 2026-07-25 15:50:56 +02:00
1 parent cfa602d5b1
commit 8e1320cb34
34 files changed
+294 -9

No files matched your search

+1
View File
@@ -820,6 +820,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`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`)
- 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
- Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday`
- 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`)
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "متوفر في مكتبتك",
"ACTOR_SEARCH_IN_PORTAL": "البحث في البوابة",
"ACTOR_BORN": "تاريخ الميلاد:",
"ACTOR_DIED": "توفي",
"SERIES_STATUS_RETURNING": "مستمر",
"SERIES_STATUS_PLANNED": "مخطط",
"SERIES_STATUS_IN_PRODUCTION": "قيد الإنتاج",
"SERIES_STATUS_ENDED": "منتهي",
"SERIES_STATUS_CANCELED": "ملغى",
"SERIES_STATUS_PILOT": "حلقة تجريبية",
"DURATION": "المدة",
"CAST": "طاقم العمل",
"EPISODE_RUN_TIME": "مدة الحلقة",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "كاين فالمكتبة ديالك",
"ACTOR_SEARCH_IN_PORTAL": "قلب فالبوابة",
"ACTOR_BORN": "مزداد:",
"ACTOR_DIED": "توفا",
"SERIES_STATUS_RETURNING": "مستمر",
"SERIES_STATUS_PLANNED": "مخطط",
"SERIES_STATUS_IN_PRODUCTION": "كيتصور",
"SERIES_STATUS_ENDED": "سالا",
"SERIES_STATUS_CANCELED": "تلغا",
"SERIES_STATUS_PILOT": "حلقة تجريبية",
"DURATION": "المدة",
"CAST": "طاقم التمثيل",
"EPISODE_RUN_TIME": "مدة تشغيل الحلقة",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "Ёсць у вашай бібліятэцы",
"ACTOR_SEARCH_IN_PORTAL": "Пошук на партале",
"ACTOR_BORN": "Нарадзіўся:",
"ACTOR_DIED": "Памёр",
"SERIES_STATUS_RETURNING": "Працягваецца",
"SERIES_STATUS_PLANNED": "Запланаваны",
"SERIES_STATUS_IN_PRODUCTION": "У вытворчасці",
"SERIES_STATUS_ENDED": "Завершаны",
"SERIES_STATUS_CANCELED": "Скасаваны",
"SERIES_STATUS_PILOT": "Пілот",
"DURATION": "Працягласць",
"CAST": "Акцёрскі склад",
"EPISODE_RUN_TIME": "Працягласць эпізоду",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "In Ihrer Bibliothek",
"ACTOR_SEARCH_IN_PORTAL": "Im Portal suchen",
"ACTOR_BORN": "Geboren:",
"ACTOR_DIED": "Gestorben",
"SERIES_STATUS_RETURNING": "Laufend",
"SERIES_STATUS_PLANNED": "Geplant",
"SERIES_STATUS_IN_PRODUCTION": "In Produktion",
"SERIES_STATUS_ENDED": "Abgeschlossen",
"SERIES_STATUS_CANCELED": "Abgesetzt",
"SERIES_STATUS_PILOT": "Pilot",
"DURATION": "Dauer",
"CAST": "Gießen",
"EPISODE_RUN_TIME": "Laufzeit der Episode",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "Στη βιβλιοθήκη σας",
"ACTOR_SEARCH_IN_PORTAL": "Αναζήτηση στην πύλη",
"ACTOR_BORN": "Γεννήθηκε:",
"ACTOR_DIED": "Απεβίωσε",
"SERIES_STATUS_RETURNING": "Σε εξέλιξη",
"SERIES_STATUS_PLANNED": "Προγραμματισμένο",
"SERIES_STATUS_IN_PRODUCTION": "Σε παραγωγή",
"SERIES_STATUS_ENDED": "Ολοκληρώθηκε",
"SERIES_STATUS_CANCELED": "Ακυρώθηκε",
"SERIES_STATUS_PILOT": "Πιλότος",
"DURATION": "Διάρκεια",
"CAST": "Πρόσωπα",
"EPISODE_RUN_TIME": "Διάρκεια επεισοδίου",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "In your library",
"ACTOR_SEARCH_IN_PORTAL": "Search in portal",
"ACTOR_BORN": "Born",
"ACTOR_DIED": "Died",
"SERIES_STATUS_RETURNING": "Returning",
"SERIES_STATUS_PLANNED": "Planned",
"SERIES_STATUS_IN_PRODUCTION": "In production",
"SERIES_STATUS_ENDED": "Ended",
"SERIES_STATUS_CANCELED": "Canceled",
"SERIES_STATUS_PILOT": "Pilot",
"DURATION": "Duration",
"CAST": "Cast",
"EPISODE_RUN_TIME": "Episode run time",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "En tu biblioteca",
"ACTOR_SEARCH_IN_PORTAL": "Buscar en el portal",
"ACTOR_BORN": "Nacimiento:",
"ACTOR_DIED": "Fallecido",
"SERIES_STATUS_RETURNING": "En emisión",
"SERIES_STATUS_PLANNED": "Planificada",
"SERIES_STATUS_IN_PRODUCTION": "En producción",
"SERIES_STATUS_ENDED": "Finalizada",
"SERIES_STATUS_CANCELED": "Cancelada",
"SERIES_STATUS_PILOT": "Piloto",
"DURATION": "Duración",
"CAST": "Elenco",
"EPISODE_RUN_TIME": "Tiempo de ejecución del episodio",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "Dans votre bibliothèque",
"ACTOR_SEARCH_IN_PORTAL": "Rechercher dans le portail",
"ACTOR_BORN": "Naissance :",
"ACTOR_DIED": "Décédé",
"SERIES_STATUS_RETURNING": "En cours",
"SERIES_STATUS_PLANNED": "Planifiée",
"SERIES_STATUS_IN_PRODUCTION": "En production",
"SERIES_STATUS_ENDED": "Terminée",
"SERIES_STATUS_CANCELED": "Annulée",
"SERIES_STATUS_PILOT": "Pilote",
"DURATION": "Durée",
"CAST": "Distribution",
"EPISODE_RUN_TIME": "Durée de l'épisode",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "A médiatárban",
"ACTOR_SEARCH_IN_PORTAL": "Keresés a portálon",
"ACTOR_BORN": "Születési dátum",
"ACTOR_DIED": "Elhunyt",
"SERIES_STATUS_RETURNING": "Folyamatban",
"SERIES_STATUS_PLANNED": "Tervezett",
"SERIES_STATUS_IN_PRODUCTION": "Gyártás alatt",
"SERIES_STATUS_ENDED": "Befejezett",
"SERIES_STATUS_CANCELED": "Törölt",
"SERIES_STATUS_PILOT": "Pilot",
"DURATION": "Időtartam",
"CAST": "Szereplők",
"EPISODE_RUN_TIME": "Epizód játékideje",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "Nella tua libreria",
"ACTOR_SEARCH_IN_PORTAL": "Cerca nel portale",
"ACTOR_BORN": "Nato:",
"ACTOR_DIED": "Deceduto",
"SERIES_STATUS_RETURNING": "In corso",
"SERIES_STATUS_PLANNED": "Pianificata",
"SERIES_STATUS_IN_PRODUCTION": "In produzione",
"SERIES_STATUS_ENDED": "Conclusa",
"SERIES_STATUS_CANCELED": "Cancellata",
"SERIES_STATUS_PILOT": "Pilota",
"DURATION": "Durata",
"CAST": "Cast",
"EPISODE_RUN_TIME": "Tempo esecuzione episodio",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "ライブラリにあり",
"ACTOR_SEARCH_IN_PORTAL": "ポータルで検索",
"ACTOR_BORN": "生年月日:",
"ACTOR_DIED": "死去",
"SERIES_STATUS_RETURNING": "放送中",
"SERIES_STATUS_PLANNED": "企画中",
"SERIES_STATUS_IN_PRODUCTION": "製作中",
"SERIES_STATUS_ENDED": "完結",
"SERIES_STATUS_CANCELED": "打ち切り",
"SERIES_STATUS_PILOT": "パイロット",
"DURATION": "時間",
"CAST": "キャスト",
"EPISODE_RUN_TIME": "エピソード再生時間",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "내 라이브러리에 있음",
"ACTOR_SEARCH_IN_PORTAL": "포털에서 검색",
"ACTOR_BORN": "출생:",
"ACTOR_DIED": "사망",
"SERIES_STATUS_RETURNING": "방영 중",
"SERIES_STATUS_PLANNED": "기획 중",
"SERIES_STATUS_IN_PRODUCTION": "제작 중",
"SERIES_STATUS_ENDED": "완결",
"SERIES_STATUS_CANCELED": "취소됨",
"SERIES_STATUS_PILOT": "파일럿",
"DURATION": "지속",
"CAST": "깁스",
"EPISODE_RUN_TIME": "에피소드 실행 시간",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "In je bibliotheek",
"ACTOR_SEARCH_IN_PORTAL": "Zoeken in portal",
"ACTOR_BORN": "Geboren:",
"ACTOR_DIED": "Overleden",
"SERIES_STATUS_RETURNING": "Lopend",
"SERIES_STATUS_PLANNED": "Gepland",
"SERIES_STATUS_IN_PRODUCTION": "In productie",
"SERIES_STATUS_ENDED": "Afgelopen",
"SERIES_STATUS_CANCELED": "Geannuleerd",
"SERIES_STATUS_PILOT": "Pilot",
"DURATION": "Lengte",
"CAST": "Cast",
"EPISODE_RUN_TIME": "Aflevering lengte",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "W twojej bibliotece",
"ACTOR_SEARCH_IN_PORTAL": "Szukaj w portalu",
"ACTOR_BORN": "Data urodzenia:",
"ACTOR_DIED": "Zmarł",
"SERIES_STATUS_RETURNING": "W trakcie",
"SERIES_STATUS_PLANNED": "Zaplanowany",
"SERIES_STATUS_IN_PRODUCTION": "W produkcji",
"SERIES_STATUS_ENDED": "Zakończony",
"SERIES_STATUS_CANCELED": "Anulowany",
"SERIES_STATUS_PILOT": "Pilot",
"DURATION": "Czas trwania",
"CAST": "Obsada",
"EPISODE_RUN_TIME": "Czas trwania odcinka",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "Na sua biblioteca",
"ACTOR_SEARCH_IN_PORTAL": "Buscar no portal",
"ACTOR_BORN": "Nascimento:",
"ACTOR_DIED": "Falecido",
"SERIES_STATUS_RETURNING": "Em exibição",
"SERIES_STATUS_PLANNED": "Planeada",
"SERIES_STATUS_IN_PRODUCTION": "Em produção",
"SERIES_STATUS_ENDED": "Terminada",
"SERIES_STATUS_CANCELED": "Cancelada",
"SERIES_STATUS_PILOT": "Piloto",
"DURATION": "Duração",
"CAST": "Elenco",
"EPISODE_RUN_TIME": "Tempo de execução do episódio",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "Есть в вашей библиотеке",
"ACTOR_SEARCH_IN_PORTAL": "Искать на портале",
"ACTOR_BORN": "Родился:",
"ACTOR_DIED": "Умер",
"SERIES_STATUS_RETURNING": "Продолжается",
"SERIES_STATUS_PLANNED": "Запланирован",
"SERIES_STATUS_IN_PRODUCTION": "В производстве",
"SERIES_STATUS_ENDED": "Завершён",
"SERIES_STATUS_CANCELED": "Закрыт",
"SERIES_STATUS_PILOT": "Пилот",
"DURATION": "Продолжительность",
"CAST": "Бросать",
"EPISODE_RUN_TIME": "Длительность эпизода",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "Kitaplığınızda mevcut",
"ACTOR_SEARCH_IN_PORTAL": "Portalda ara",
"ACTOR_BORN": "Doğum:",
"ACTOR_DIED": "Vefat",
"SERIES_STATUS_RETURNING": "Devam ediyor",
"SERIES_STATUS_PLANNED": "Planlandı",
"SERIES_STATUS_IN_PRODUCTION": "Yapım aşamasında",
"SERIES_STATUS_ENDED": "Tamamlandı",
"SERIES_STATUS_CANCELED": "İptal edildi",
"SERIES_STATUS_PILOT": "Pilot",
"DURATION": "Süre",
"CAST": "Oyuncular",
"EPISODE_RUN_TIME": "Bölüm süresi",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "已在您的媒体库中",
"ACTOR_SEARCH_IN_PORTAL": "在门户中搜索",
"ACTOR_BORN": "出生:",
"ACTOR_DIED": "逝世",
"SERIES_STATUS_RETURNING": "连载中",
"SERIES_STATUS_PLANNED": "筹备中",
"SERIES_STATUS_IN_PRODUCTION": "制作中",
"SERIES_STATUS_ENDED": "已完结",
"SERIES_STATUS_CANCELED": "已取消",
"SERIES_STATUS_PILOT": "试播",
"DURATION": "期间",
"CAST": "投掷",
"EPISODE_RUN_TIME": "剧集运行时间",
+7
View File
@@ -802,6 +802,13 @@
"ACTOR_IN_LIBRARY": "已在您的媒體庫中",
"ACTOR_SEARCH_IN_PORTAL": "在入口網站搜尋",
"ACTOR_BORN": "出生:",
"ACTOR_DIED": "逝世",
"SERIES_STATUS_RETURNING": "連載中",
"SERIES_STATUS_PLANNED": "籌備中",
"SERIES_STATUS_IN_PRODUCTION": "製作中",
"SERIES_STATUS_ENDED": "已完結",
"SERIES_STATUS_CANCELED": "已取消",
"SERIES_STATUS_PILOT": "試播",
"DURATION": "片長",
"CAST": "演員陣容",
"EPISODE_RUN_TIME": "集數時長",
@@ -68,7 +68,12 @@ item lands. Enriched cast (`tmdb_cast` with profile photos) renders as
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
carry `tmdbPersonId` and open the same person page. Series detail views
also show a production-status chip (`tmdb_status`): TMDB returns `status`
as an ENGLISH string regardless of the request language, so it is
normalized to a token (`normalizeSeriesStatus`) and rendered through
translated labels (`seriesStatusLabelKey`) — the raw value never reaches
the UI. Unknown values are dropped rather than displayed. A "check key" button
in the settings section validates the API key against `/configuration`.
## Match Confidence
@@ -161,6 +161,7 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
...(info.tmdb_directors
? { tmdb_directors: info.tmdb_directors }
: {}),
...(info.tmdb_status ? { tmdb_status: info.tmdb_status } : {}),
...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}),
...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}),
...(info.tmdb_recommendations
@@ -10,6 +10,11 @@
>
<!-- Tags: Year, Genre, Rating -->
<ng-template appDetailTags>
@if (serial.info.tmdb_status; as status) {
<span class="details__tag details__tag--status">{{
seriesStatusLabelKey(status) | translate
}}</span>
}
@if (serial.info.releasedate) {
<span class="details__tag">{{ serial.info.releasedate }}</span>
}
@@ -23,11 +23,12 @@ import {
SeasonContainerPlaybackToggleRequest,
} from '@iptvnator/ui/components';
import {
pickSeasonMarkedTitle,
PlaybackPositionData,
ResolvedPortalPlayback,
seriesStatusLabelKey,
TmdbEnrichedCastMember,
XtreamSerieEpisode,
pickSeasonMarkedTitle,
youtubeEmbedUrl,
} from '@iptvnator/shared/interfaces';
import { SafePipe } from '@iptvnator/pipes';
@@ -135,6 +136,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
private readonly tmdbSeasons = inject(StalkerSeriesTmdbSeasonsService);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
/** Maps the status token to its translated label key */
readonly seriesStatusLabelKey = seriesStatusLabelKey;
/**
* TMDB recommendations found in the user's Xtream portals (batched DB
* match, Electron only) — Stalker catalogs are server-paginated, so
@@ -11,6 +11,11 @@
(closePlayerRequested)="closeInlinePlayer()"
>
<ng-template appDetailTags>
@if (info.tmdb_status; as status) {
<span class="details__tag details__tag--status">{{
seriesStatusLabelKey(status) | translate
}}</span>
}
@if (info.releaseDate) {
<span class="details__tag">{{
info.releaseDate | slice: 0 : 4
@@ -31,11 +31,12 @@ import {
type UpNextRailItem,
} from '@iptvnator/ui/playback';
import {
TmdbEnrichedCastMember,
XtreamSerieInfo,
XtreamSerieEpisode,
XtreamSerieDetails,
normalizeTitleKeys,
seriesStatusLabelKey,
TmdbEnrichedCastMember,
XtreamSerieDetails,
XtreamSerieEpisode,
XtreamSerieInfo,
} from '@iptvnator/shared/interfaces';
import {
CrossPortalSimilarItem,
@@ -84,6 +85,9 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
/** Maps the status token to its translated label key */
readonly seriesStatusLabelKey = seriesStatusLabelKey;
private readonly xtreamStore = inject(XtreamStore);
private readonly playback = inject(SerialDetailsPlaybackService);
private readonly snackBar = inject(MatSnackBar);
@@ -303,3 +303,52 @@ describe('mergeSerieInfoWithTmdb', () => {
expect(merged.cover).toBe('http://provider/cover.jpg');
});
});
describe('series status', () => {
const info: XtreamSerieInfo = {
name: 'The Boys',
cover: '',
plot: '',
cast: '',
director: '',
genre: '',
releaseDate: '',
last_modified: '',
rating: '',
rating_5based: 0,
backdrop_path: [],
youtube_trailer: '',
episode_run_time: '',
category_id: '1',
};
const base: TmdbTvDetails = { id: 76479, name: 'The Boys' };
it('maps TMDB status strings to stable tokens', () => {
expect(
mergeSerieInfoWithTmdb(info, {
...base,
status: 'Returning Series',
}).tmdb_status
).toBe('returning');
expect(
mergeSerieInfoWithTmdb(info, { ...base, status: 'Ended' })
.tmdb_status
).toBe('ended');
});
it('is case-insensitive and accepts the British spelling', () => {
expect(
mergeSerieInfoWithTmdb(info, { ...base, status: 'cancelled' })
.tmdb_status
).toBe('canceled');
});
it('omits the field for unknown or missing statuses', () => {
// Never surface a raw English string TMDB might add later
expect(
mergeSerieInfoWithTmdb(info, { ...base, status: 'Something New' })
.tmdb_status
).toBeUndefined();
expect(mergeSerieInfoWithTmdb(info, base).tmdb_status).toBeUndefined();
});
});
+8
View File
@@ -1,4 +1,5 @@
import {
normalizeSeriesStatus,
StalkerVodInfo,
TmdbEnrichedCastMember,
TmdbMediaType,
@@ -218,6 +219,7 @@ export function mergeSerieInfoWithTmdb(
): XtreamSerieInfo {
const tmdbCast = enrichedCast(details.credits);
const tmdbDirectors = enrichedCreators(details);
const status = normalizeSeriesStatus(details.status);
const trailer = pickTrailerKey(details);
const recommendations = recommendationList(details);
const cast = castNames(details.credits);
@@ -240,6 +242,7 @@ export function mergeSerieInfoWithTmdb(
backdrop_path: mergedBackdrops(details, info.backdrop_path),
youtube_trailer: prefer(trailer, info.youtube_trailer),
tmdb_id: details.id,
...(status ? { tmdb_status: status } : {}),
...(tmdbDirectors.length > 0 ? { tmdb_directors: tmdbDirectors } : {}),
...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}),
...(recommendations.length > 0
@@ -264,6 +267,10 @@ export function mergeStalkerInfoWithTmdb(
mediaType === 'movie'
? enrichedDirectors(details.credits)
: enrichedCreators(details as TmdbTvDetails);
const status =
mediaType === 'tv'
? normalizeSeriesStatus((details as TmdbTvDetails).status)
: null;
const trailer = pickTrailerKey(details);
const recommendations = recommendationList(details);
const cast = castNames(details.credits);
@@ -291,6 +298,7 @@ export function mergeStalkerInfoWithTmdb(
rating_imdb:
info.rating_imdb || (rating !== null ? String(rating) : ''),
tmdb_id: details.id,
...(status ? { tmdb_status: status } : {}),
...(backdrop ? { tmdb_backdrop: backdrop } : {}),
...(trailer ? { tmdb_trailer: trailer } : {}),
...(tmdbDirectors.length > 0 ? { tmdb_directors: tmdbDirectors } : {}),
+2
View File
@@ -98,6 +98,8 @@ export interface TmdbTvDetails extends TmdbDetailsBase {
name?: string;
original_name?: string;
first_air_date?: string;
/** English production status ("Ended", "Returning Series", ...) */
status?: string;
episode_run_time?: number[];
created_by?: {
id?: number;
@@ -1,6 +1,7 @@
import {
TmdbEnrichedCastMember,
TmdbRecommendation,
TmdbSeriesStatus,
} from './tmdb.interface';
/**
@@ -41,6 +42,8 @@ export interface StalkerVodInfo {
tmdb_cast?: TmdbEnrichedCastMember[];
/** Directors (movies) / creators (series) as clickable person chips */
tmdb_directors?: TmdbEnrichedCastMember[];
/** Series production status (token, never a raw TMDB string) */
tmdb_status?: TmdbSeriesStatus;
/** TMDB backdrop URL — Stalker portals never provide one themselves */
tmdb_backdrop?: string;
/** YouTube trailer key from TMDB — Stalker portals provide no trailers */
@@ -33,6 +33,51 @@ export const DEFAULT_TMDB_SETTINGS: TmdbSettings = {
* Cast member attached to provider detail objects after TMDB enrichment.
* Rendered as avatar chips in the VOD/series detail views.
*/
/**
* Production status of a series, as a stable token. TMDB returns this
* field as an ENGLISH string regardless of the request language, so the
* raw value must never reach the UI — it is normalized here and rendered
* through translated labels.
*/
export type TmdbSeriesStatus =
| 'returning'
| 'planned'
| 'in-production'
| 'ended'
| 'canceled'
| 'pilot';
const SERIES_STATUS_TOKENS: Record<string, TmdbSeriesStatus> = {
'returning series': 'returning',
planned: 'planned',
'in production': 'in-production',
ended: 'ended',
canceled: 'canceled',
cancelled: 'canceled',
pilot: 'pilot',
};
/** `null` for unknown values — an unmapped status is simply not shown */
export function normalizeSeriesStatus(
status: string | null | undefined
): TmdbSeriesStatus | null {
const key = status?.trim().toLowerCase();
return (key && SERIES_STATUS_TOKENS[key]) || null;
}
const SERIES_STATUS_LABEL_KEYS: Record<TmdbSeriesStatus, string> = {
returning: 'XTREAM.SERIES_STATUS_RETURNING',
planned: 'XTREAM.SERIES_STATUS_PLANNED',
'in-production': 'XTREAM.SERIES_STATUS_IN_PRODUCTION',
ended: 'XTREAM.SERIES_STATUS_ENDED',
canceled: 'XTREAM.SERIES_STATUS_CANCELED',
pilot: 'XTREAM.SERIES_STATUS_PILOT',
};
export function seriesStatusLabelKey(status: TmdbSeriesStatus): string {
return SERIES_STATUS_LABEL_KEYS[status];
}
export interface TmdbEnrichedCastMember {
name: string;
character?: string;
@@ -1,6 +1,7 @@
import {
TmdbEnrichedCastMember,
TmdbRecommendation,
TmdbSeriesStatus,
} from './tmdb.interface';
export interface XtreamSerieDetails {
@@ -28,6 +29,8 @@ export interface XtreamSerieInfo {
tmdb_cast?: TmdbEnrichedCastMember[];
/** Directors (movies) / creators (series) as clickable person chips */
tmdb_directors?: TmdbEnrichedCastMember[];
/** Series production status (token, never a raw TMDB string) */
tmdb_status?: TmdbSeriesStatus;
/** Populated by TMDB enrichment; matched against the catalog in views */
tmdb_recommendations?: TmdbRecommendation[];
/** Matched TMDB show id — enables lazy season/episode enrichment */
@@ -27,10 +27,19 @@
}
<div class="actor-view__meta">
<h1>{{ person.name }}</h1>
@if (person.birthday) {
@if (person.birthday || person.deathday) {
<p class="actor-view__born">
{{ 'XTREAM.ACTOR_BORN' | translate }}
{{ person.birthday }}
@if (person.birthday) {
{{ 'XTREAM.ACTOR_BORN' | translate }}
{{ person.birthday }}
}
@if (person.deathday) {
@if (person.birthday) {
·
}
{{ 'XTREAM.ACTOR_DIED' | translate }}
{{ person.deathday }}
}
@if (person.placeOfBirth) {
· {{ person.placeOfBirth }}
}
+8
View File
@@ -135,6 +135,14 @@
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
// Series production status — a state, not a fact about the
// title, so it reads quieter than the other tags
&--status {
text-transform: uppercase;
font-size: 0.6875rem;
letter-spacing: 0.06em;
}
}
&__meta-item {