mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* feat(tmdb): clickable director/creator chips and directing credits on person pages Directors were plain merged text — no photos, no navigation — while the data was already sitting in the cached TMDB payloads (credits.crew and created_by both carry id + profile_path; they just were not typed or parsed). - tmdb-merge: enrichedDirectors (crew, job === 'Director', deduped by person id) and enrichedCreators (created_by) produce the same chip shape as the cast (TmdbEnrichedCastMember) into a new tmdb_directors field on all three merges (Xtream VOD, Xtream series, Stalker); types widened (crew id/profile_path, created_by id/profile_path). - Detail views (shared VodDetailsComponent, Xtream vod/serial routes, Stalker series view) render the Director row as clickable avatar chips when tmdb_directors is present — same markup and openActor handler as the cast strip — falling back to the plain text otherwise. Stalker re-normalization allowlist preserves the new field. - Person pages: mapPersonFilmography now merges combined_credits.crew (jobs Director/Creator) into the filmography — acting wins the per-title dedup, directing-only titles show the job in the character slot. Everything else (library matching, All-portals scope, filters, search fallback, back button) works unchanged because the person page is role-agnostic. Existing caches work as-is: crew/created_by were always part of the stored payloads. Tests: merge spec (director/creator chips + crew-row dedup ×3 merges), person spec (crew credits, Producer excluded, acting-wins dedup), stalker-vod.utils passthrough. Docs updated (CLAUDE.md + architecture). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): address director-pages review — split oversized spec, stable track keys, translated crew roles - tmdb-merge.spec.ts grew past the 400-line lint ceiling — the Stalker merge suite moved to tmdb-merge-stalker.spec.ts (fixes the CI Lint job). - All cast/director chip loops now track by TMDB person id with an index fallback ('p<id>' / 'i<index>') instead of member.name — distinct people can share a name and creator payloads carry no dedup (greptile). - Directing-only filmography credits carry the role in a new crewJob field ('Director' | 'Creator') instead of stuffing TMDB's raw English job into character; ActorViewComponent renders it through translated labels (XTREAM.CREW_JOB_DIRECTOR/CREATOR, added to all 18 locales via the i18n patch workflow, matching each locale's existing glossary — pt "Diretor", de "Regisseur") (Codex). Tests: person spec asserts character/crewJob separation; merge suites green after the split (15 + stalker file). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
import {
|
|
StalkerVodInfo,
|
|
TmdbEnrichedCastMember,
|
|
TmdbMediaType,
|
|
TmdbRecommendation,
|
|
XtreamSerieInfo,
|
|
XtreamVodInfo,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { tmdbBackdropUrl, tmdbPosterUrl, tmdbProfileUrl } from './tmdb-config';
|
|
import { extractYear } from './tmdb-matcher';
|
|
import {
|
|
TmdbCredits,
|
|
TmdbDetails,
|
|
TmdbMovieDetails,
|
|
TmdbTvDetails,
|
|
} from './tmdb.types';
|
|
|
|
/**
|
|
* Field-level merge of TMDB metadata into provider detail objects.
|
|
* The provider stays authoritative for stream-related data; TMDB wins for
|
|
* editorial fields (plot, cast, director, genres, rating, artwork) when it
|
|
* has a value, otherwise the provider value is kept. Nothing is mutated.
|
|
*/
|
|
|
|
const MAX_CAST_NAMES = 10;
|
|
|
|
function topCast(credits: TmdbCredits | undefined) {
|
|
return [...(credits?.cast ?? [])]
|
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
.slice(0, MAX_CAST_NAMES)
|
|
.filter((member) => Boolean(member.name));
|
|
}
|
|
|
|
function castNames(credits: TmdbCredits | undefined): string {
|
|
return topCast(credits)
|
|
.map((member) => member.name)
|
|
.join(', ');
|
|
}
|
|
|
|
/** Cast with profile photos for the avatar chips in detail views */
|
|
function enrichedCast(
|
|
credits: TmdbCredits | undefined
|
|
): TmdbEnrichedCastMember[] {
|
|
return topCast(credits).map((member) => ({
|
|
name: member.name,
|
|
...(member.character ? { character: member.character } : {}),
|
|
profileUrl: tmdbProfileUrl(member.profile_path),
|
|
...(member.id ? { tmdbPersonId: member.id } : {}),
|
|
}));
|
|
}
|
|
|
|
function directorNames(credits: TmdbCredits | undefined): string {
|
|
return (credits?.crew ?? [])
|
|
.filter((member) => member.job === 'Director')
|
|
.map((member) => member.name)
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
}
|
|
|
|
function creatorNames(details: TmdbTvDetails): string {
|
|
return (details.created_by ?? [])
|
|
.map((creator) => creator.name)
|
|
.filter(Boolean)
|
|
.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<number>();
|
|
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 */
|
|
function pickTrailerKey(details: TmdbDetails): string {
|
|
const videos = (details.videos?.results ?? []).filter(
|
|
(video) => video.site === 'YouTube' && Boolean(video.key)
|
|
);
|
|
const best =
|
|
videos.find((video) => video.type === 'Trailer' && video.official) ??
|
|
videos.find((video) => video.type === 'Trailer') ??
|
|
videos.find((video) => video.type === 'Teaser');
|
|
return best?.key ?? '';
|
|
}
|
|
|
|
function recommendationList(details: TmdbDetails): TmdbRecommendation[] {
|
|
return (details.recommendations?.results ?? [])
|
|
.slice(0, MAX_RECOMMENDATIONS)
|
|
.map((result) => ({
|
|
tmdbId: result.id,
|
|
title: (result.title ?? result.name ?? '').trim(),
|
|
year: extractYear(result.release_date ?? result.first_air_date),
|
|
posterUrl: tmdbPosterUrl(result.poster_path),
|
|
}))
|
|
.filter((recommendation) => recommendation.title !== '');
|
|
}
|
|
|
|
function genreNames(details: TmdbDetails): string {
|
|
return (details.genres ?? [])
|
|
.map((genre) => genre.name)
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
}
|
|
|
|
function tmdbRating(details: TmdbDetails): number | null {
|
|
const { vote_average: average, vote_count: count } = details;
|
|
return average && average > 0 && count && count > 0
|
|
? Math.round(average * 10) / 10
|
|
: null;
|
|
}
|
|
|
|
/** TMDB backdrop first, then the provider's own entries (deduplicated) */
|
|
function mergedBackdrops(
|
|
details: TmdbDetails,
|
|
// Some panels send backdrop_path as a plain string despite the typing
|
|
providerBackdrops: string[] | string | undefined
|
|
): string[] {
|
|
const tmdbUrl = tmdbBackdropUrl(details.backdrop_path);
|
|
const provider = (
|
|
Array.isArray(providerBackdrops)
|
|
? providerBackdrops
|
|
: [providerBackdrops]
|
|
).filter((value): value is string => typeof value === 'string' && !!value);
|
|
return tmdbUrl
|
|
? [tmdbUrl, ...provider.filter((url) => url !== tmdbUrl)]
|
|
: provider;
|
|
}
|
|
|
|
function prefer(tmdbValue: string | null | undefined, providerValue: string) {
|
|
return tmdbValue?.trim() ? tmdbValue : providerValue;
|
|
}
|
|
|
|
export function mergeVodInfoWithTmdb(
|
|
info: XtreamVodInfo,
|
|
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);
|
|
const director = directorNames(details.credits);
|
|
const genre = genreNames(details);
|
|
const rating = tmdbRating(details);
|
|
const poster = tmdbPosterUrl(details.poster_path);
|
|
const country = (details.production_countries ?? [])
|
|
.map((entry) => entry.name)
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
|
|
return {
|
|
...info,
|
|
tmdb_id: details.id,
|
|
plot: prefer(details.overview, info.plot),
|
|
description: prefer(details.overview, info.description),
|
|
cast: prefer(cast, info.cast),
|
|
actors: prefer(cast, info.actors),
|
|
director: prefer(director, info.director),
|
|
genre: prefer(genre, info.genre),
|
|
rating: rating ?? info.rating,
|
|
// The VOD detail badge renders rating_imdb — fill it when the
|
|
// provider left it empty so a TMDB-only score is actually shown
|
|
rating_imdb:
|
|
info.rating_imdb || (rating !== null ? String(rating) : ''),
|
|
releasedate: info.releasedate || (details.release_date ?? ''),
|
|
country: info.country || country,
|
|
movie_image: prefer(poster, info.movie_image),
|
|
cover_big: prefer(poster, info.cover_big),
|
|
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 }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export function mergeSerieInfoWithTmdb(
|
|
info: XtreamSerieInfo,
|
|
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);
|
|
const creators = creatorNames(details);
|
|
const genre = genreNames(details);
|
|
const rating = tmdbRating(details);
|
|
const poster = tmdbPosterUrl(details.poster_path);
|
|
|
|
return {
|
|
...info,
|
|
plot: prefer(details.overview, info.plot),
|
|
cast: prefer(cast, info.cast),
|
|
director: prefer(creators, info.director),
|
|
genre: prefer(genre, info.genre),
|
|
rating: rating !== null ? String(rating) : info.rating,
|
|
rating_5based:
|
|
rating !== null ? Math.round(rating * 5) / 10 : info.rating_5based,
|
|
releaseDate: info.releaseDate || (details.first_air_date ?? ''),
|
|
cover: prefer(poster, info.cover),
|
|
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 }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Merge for Stalker portal detail objects. One function covers movies and
|
|
* series — both render from the same `StalkerVodInfo` shape. The TMDB
|
|
* rating only FILLS `rating_imdb` (the UI labels it as IMDb, so a present
|
|
* provider value must not be overwritten with a TMDB score).
|
|
*/
|
|
export function mergeStalkerInfoWithTmdb(
|
|
info: StalkerVodInfo,
|
|
details: TmdbMovieDetails | TmdbTvDetails,
|
|
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);
|
|
const director =
|
|
mediaType === 'movie'
|
|
? directorNames(details.credits)
|
|
: creatorNames(details as TmdbTvDetails);
|
|
const genre = genreNames(details);
|
|
const rating = tmdbRating(details);
|
|
const poster = tmdbPosterUrl(details.poster_path);
|
|
const backdrop = tmdbBackdropUrl(details.backdrop_path);
|
|
const releaseDate =
|
|
mediaType === 'movie'
|
|
? (details as TmdbMovieDetails).release_date
|
|
: (details as TmdbTvDetails).first_air_date;
|
|
|
|
return {
|
|
...info,
|
|
description: prefer(details.overview, info.description),
|
|
actors: prefer(cast, info.actors),
|
|
director: prefer(director, info.director),
|
|
genre: prefer(genre, info.genre),
|
|
releasedate: info.releasedate || (releaseDate ?? ''),
|
|
movie_image: prefer(poster, info.movie_image),
|
|
rating_imdb:
|
|
info.rating_imdb || (rating !== null ? String(rating) : ''),
|
|
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 }
|
|
: {}),
|
|
};
|
|
}
|