feat(tmdb): dashboard trending + hero enrichment, cross-portal Similar rail (#1132)

* feat(dashboard): TMDB trending rail and hero enrichment (backdrop, badges, S/E)

Two dashboard additions, both async after first paint so the page renders
exactly as fast as before:

Trending rail ("Trending this week", dashboardRails.tmdbTrending toggle,
default on, rendered only when TMDB is opted in AND the Electron DB
worker is available):
- TmdbTrendingService fetches /trending/{movie,tv}/week (one request
  each, cached one day per language in tmdb_metadata under
  trending:week), merges by popularity; exposed via the enrichment
  facade (getTrendingWeek)
- DashboardTrendingService matches the titles against imported Xtream
  playlists with ONE batched DB_MATCH_TITLES request, applying the same
  two-tier + year-compatibility rule as actor pages; matched cards show
  the playlist name and navigate straight to the detail view, unmatched
  cards open the global search prefilled (?q=)
- The load fires only after the dashboard's own recent/favorites data
  is in (never competes for the worker at startup) and once per session
- DashboardRailCard gained optional queryParams for the search links

Hero enrichment:
- DashboardHeroTmdbService patches the hero with a TMDB backdrop (only
  when the item has none), a rating badge and up to two genre chips —
  via the enrichment facade, so previously opened items resolve from
  the SQLite cache without network; memoized per title per session,
  staleness-guarded against hero changes in flight
- Series heroes show the tracked "S{n}·E{n}" badge from the playback
  position; the watch-progress bar no longer applies to live heroes

Settings: new dashboardRails.tmdbTrending toggle in Settings > Dashboard.
i18n: 3 new keys translated into all 17 locales via tools/i18n patches.

Tests: dashboard-trending.service.spec.ts (gating, matching, year guard,
single-flight); settings fixtures updated. Docs updated
(tmdb-metadata-enrichment.md Dashboard Integration section, CLAUDE.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): resolve hero TMDB extras for Stalker embedded-series items

Stalker vclub items carry type 'movie' in activity rows but are TV shows
on TMDB, so the hero's movie lookup found no confident match and the
backdrop/badges never appeared — while the detail view (which resolves
via is_series) showed them. When a movie-typed hero item has no movie
match, retry the lookup as TV: the detail view has usually already
cached that resolution, and misses are negative-cached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmdb): cross-portal "Similar" rail — Stalker gets it, Xtream gains other-portal matches

The Similar rail only existed on Xtream because it matched against the
locally loaded catalog; Stalker catalogs are server-paginated, so its
detail views had no rail despite tmdb_recommendations being cached.

New CrossPortalSimilarService (libs/services) matches recommendations
against ALL imported Xtream playlists with one batched DB_MATCH_TITLES
worker request — the same two-tier normalized-title + year-compatibility
rule as actor pages and the trending rail. Electron-only; resolves to []
in the PWA.

- Stalker: the shared VodDetailsComponent (movies; covers catalog and
  inline detail hosts) and stalker-series-view (series) now render a
  "Similar" rail from cross-portal matches, each card badged with the
  source playlist and navigating into that portal's detail view.
- Xtream: vod/serial detail rails keep instant local-catalog matches and
  append cross-portal matches (current playlist excluded, deduplicated
  against local hits by normalized exact title), also playlist-badged.
- Loads async after the detail view renders, staleness-guarded; the
  section only appears when there is something to show.

Tests: cross-portal-similar.service.spec.ts (PWA gate, navigation
targets, playlist exclusion, type/year guards). Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmdb): drop TestBed from cross-portal similar spec

The services Jest target has no @angular/core/testing (same CI failure
as the cache spec earlier) — construct the service via Injector.create +
runInInjectionContext instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): address PR review — reactive opt-out gates, retry after empty trending load

- Trending rail and hero TMDB extras now vanish immediately when the
  TMDB opt-in is switched off mid-session: the render computeds read the
  settings signal through isAvailable/isEnabled instead of trusting data
  loaded earlier (Codex P2 ×2).
- loadedOnce latches only after a successful non-empty load, so a
  transient TMDB outage on first visit no longer suppresses the rail for
  the whole session — the next dashboard visit retries (greptile P2).
- Unified the duplicated heroTmdbExtras() read in the hero computed
  (greptile P2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-05 12:21:11 +02:00
1 parent 9635f398c1
commit 862b271eac
55 files changed
+1467 -21

No files matched your search

@@ -33,6 +33,8 @@ export interface DashboardRailsSettings {
favoriteMoviesAndSeries: boolean;
recentSources: boolean;
xtreamRecentlyAdded: boolean;
/** TMDB "Trending this week" rail (needs the TMDB opt-in; Electron) */
tmdbTrending: boolean;
}
export const DEFAULT_DASHBOARD_RAILS_SETTINGS: DashboardRailsSettings = {
@@ -43,6 +45,7 @@ export const DEFAULT_DASHBOARD_RAILS_SETTINGS: DashboardRailsSettings = {
favoriteMoviesAndSeries: true,
recentSources: true,
xtreamRecentlyAdded: true,
tmdbTrending: true,
};
export type DashboardRailsSettingsInput = Partial<
@@ -30,6 +30,7 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
ratingKinopoisk: info?.rating_kinopoisk,
youtubeTrailer: info?.youtube_trailer,
tmdbCast: info?.tmdb_cast,
tmdbRecommendations: info?.tmdb_recommendations,
};
}
@@ -58,6 +59,7 @@ export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta
// Stalker portals provide no trailers; TMDB enrichment can
youtubeTrailer: info?.tmdb_trailer,
tmdbCast: info?.tmdb_cast,
tmdbRecommendations: info?.tmdb_recommendations,
};
}
@@ -1,6 +1,8 @@
import { XtreamVodDetails } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
import { TmdbEnrichedCastMember } from './tmdb.interface';
import { TmdbEnrichedCastMember,
TmdbRecommendation,
} from './tmdb.interface';
/**
* Discriminated union for VOD details across portal types.
@@ -82,6 +84,8 @@ export interface NormalizedVodMeta {
youtubeTrailer?: string;
/** Cast with profile photos, populated by TMDB enrichment */
tmdbCast?: TmdbEnrichedCastMember[];
/** TMDB recommendations (drives the cross-portal "Similar" rail) */
tmdbRecommendations?: TmdbRecommendation[];
}
/**