feat(portals): make year, genre and country metadata clickable (#1449)

Year, genre and country chips on movie and series detail pages now open a
Discover page inside the portal: popular TMDB titles for that facet, matched
against the user's own catalog. Generalizes the existing actor-page pattern
(TMDB list -> what's in my library -> else portal search) to metadata facets.

- All three TMDB merges emit structured `tmdb_genres`/`tmdb_countries`
  (+ `tmdb_media_type` on Stalker, whose embedded-VOD series route as movies).
  Cached details payloads already carry both, so existing rows need no refetch.
- Chips are clickable only with TMDB backing, like person chips today; the
  year chip gates on a merge-written numeric `tmdb_id`, since provider
  payloads ship junk string ids.
- `TmdbDiscoverService` fetches up to 5 `/discover` pages by popularity and
  caches them in memory only — popularity rankings are volatile and must not
  reach the persisted `tmdb_metadata` table.
- New `discover` route in both portals; containers clone the actor route,
  staleness-guarded by a facet key because facets change via query params on
  the same route instance.
- The grid, filter chips and badges move out of `ActorViewComponent` into a
  shared `TitleResultsComponent` used by both pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-16 08:15:04 +02:00
1 parent 00aa623b83
commit 486d04411f
68 files changed
+2287 -318

No files matched your search

@@ -1,5 +1,8 @@
import {
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
TmdbMediaType,
TmdbRecommendation,
TmdbSeriesStatus,
} from './tmdb.interface';
@@ -52,4 +55,13 @@ export interface StalkerVodInfo {
tmdb_recommendations?: TmdbRecommendation[];
/** Matched TMDB id — enables lazy season/episode enrichment */
tmdb_id?: number;
/** Populated by TMDB enrichment; per-entry clickable Discover chips */
tmdb_genres?: TmdbGenreFacet[];
tmdb_countries?: TmdbCountryFacet[];
/**
* Media type the enrichment matched this item as. Stalker embedded-VOD
* series route as movies structurally, so the shared detail view needs
* this to send Discover clicks to the right media type.
*/
tmdb_media_type?: TmdbMediaType;
}
@@ -99,6 +99,26 @@ export interface TmdbRecommendation {
posterUrl: string | null;
}
/**
* Genre as stated by TMDB for one title. The id drives the Discover-page
* filter (`with_genres`), so it must never be mixed across media types —
* movie and TV genre id spaces differ.
*/
export interface TmdbGenreFacet {
id: number;
name: string;
}
/**
* Production country as stated by TMDB. `code` is ISO 3166-1 alpha-2 and
* drives the Discover-page filter (`with_origin_country`); `name` is the
* TMDB display name (English regardless of request language).
*/
export interface TmdbCountryFacet {
code: string;
name: string;
}
/**
* One cached TMDB lookup. Two kinds of rows share the table, discriminated
* by the `lookupKey` prefix:
@@ -32,6 +32,11 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
tmdbCast: info?.tmdb_cast,
tmdbDirectors: info?.tmdb_directors,
tmdbRecommendations: info?.tmdb_recommendations,
// Provider payloads carry tmdb_id as an untrusted string too; only
// a merge-written number proves an enrichment match
tmdbId: typeof info?.tmdb_id === 'number' ? info.tmdb_id : undefined,
tmdbGenres: info?.tmdb_genres,
tmdbCountries: info?.tmdb_countries,
};
}
@@ -62,6 +67,10 @@ export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta
tmdbCast: info?.tmdb_cast,
tmdbDirectors: info?.tmdb_directors,
tmdbRecommendations: info?.tmdb_recommendations,
tmdbId: info?.tmdb_id,
tmdbMediaType: info?.tmdb_media_type,
tmdbGenres: info?.tmdb_genres,
tmdbCountries: info?.tmdb_countries,
};
}
@@ -1,6 +1,10 @@
import { XtreamVodDetails } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
import { TmdbEnrichedCastMember,
import {
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
TmdbMediaType,
TmdbRecommendation,
} from './tmdb.interface';
@@ -88,6 +92,14 @@ export interface NormalizedVodMeta {
tmdbDirectors?: TmdbEnrichedCastMember[];
/** TMDB recommendations (drives the cross-portal "Similar" rail) */
tmdbRecommendations?: TmdbRecommendation[];
/** Matched TMDB id — gates the clickable year chip (Discover page) */
tmdbId?: number;
/** Media type the enrichment matched as; Discover click routing */
tmdbMediaType?: TmdbMediaType;
/** Per-entry clickable genre chips (Discover page) */
tmdbGenres?: TmdbGenreFacet[];
/** Per-entry clickable country chips (Discover page) */
tmdbCountries?: TmdbCountryFacet[];
}
/**
@@ -1,5 +1,7 @@
import {
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
TmdbRecommendation,
TmdbSeriesStatus,
} from './tmdb.interface';
@@ -44,6 +46,9 @@ export interface XtreamSerieInfo {
tmdb_recommendations?: TmdbRecommendation[];
/** Matched TMDB show id — enables lazy season/episode enrichment */
tmdb_id?: number;
/** Populated by TMDB enrichment; per-entry clickable Discover chips */
tmdb_genres?: TmdbGenreFacet[];
tmdb_countries?: TmdbCountryFacet[];
}
export interface XtreamSerieEpisode {
@@ -1,5 +1,7 @@
import {
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
TmdbRecommendation,
} from './tmdb.interface';
@@ -52,6 +54,9 @@ export interface XtreamVodInfo {
tmdb_directors?: TmdbEnrichedCastMember[];
/** Populated by TMDB enrichment; matched against the catalog in views */
tmdb_recommendations?: TmdbRecommendation[];
/** Populated by TMDB enrichment; per-entry clickable Discover chips */
tmdb_genres?: TmdbGenreFacet[];
tmdb_countries?: TmdbCountryFacet[];
}
export interface XtreamVodMovieData {