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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-05 11:18:31 +02:00
1 parent ce24e16e32
commit d77a6a28cb
40 files changed
+765 -3

No files matched your search

+1
View File
@@ -647,6 +647,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works
- "Similar" rail in Xtream 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`); 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`)
- 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
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); 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`)
@@ -160,5 +160,23 @@
></mat-checkbox>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="tmdbTrending"
data-test-id="toggle-dashboard-rail-tmdb-trending"
></mat-checkbox>
</div>
</div>
</div>
</section>
@@ -50,6 +50,7 @@ export function createSettingsForm(
recentSources: DEFAULT_DASHBOARD_RAILS_SETTINGS.recentSources,
xtreamRecentlyAdded:
DEFAULT_DASHBOARD_RAILS_SETTINGS.xtreamRecentlyAdded,
tmdbTrending: DEFAULT_DASHBOARD_RAILS_SETTINGS.tmdbTrending,
}),
startupBehavior: StartupBehavior.FirstView,
showExternalPlaybackBar: true,
@@ -69,6 +69,7 @@ const DEFAULT_DASHBOARD_RAILS = {
favoriteMoviesAndSeries: true,
recentSources: true,
xtreamRecentlyAdded: true,
tmdbTrending: true,
};
class MatSnackBarStub {
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "الرائج هذا الأسبوع (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "عرض الأفلام والمسلسلات الرائجة أسبوعياً من TMDB مع مطابقتها بمكتباتك. يتطلب تفعيل بيانات TMDB الوصفية.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "عرض الكل ({{count}})",
"RECENTLY_WATCHED": "ما تمت مشاهدته مؤخرًا",
"XTREAM_RECENTLY_ADDED": "أُضيفت مؤخرًا على Xtream",
"TMDB_TRENDING": "الرائج هذا الأسبوع",
"NO_HISTORY": "لم يتم العثور على عناصر لهذا الفلتر.",
"NO_HISTORY_HINT": "سيظهر سجل المشاهدة هنا بمجرد بدء تشغيل محتوى من قوائم التشغيل الخاصة بك.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "الرائج هاد السيمانة (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "عرض الأفلام والمسلسلات الرائجة فالسيمانة من TMDB مطابقين مع المكتبات ديالك. خاص تفعيل TMDB.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "شوف الكل ({{count}})",
"RECENTLY_WATCHED": "متشافين مؤخراً",
"XTREAM_RECENTLY_ADDED": "تزادو مؤخراً على Xtream",
"TMDB_TRENDING": "الرائج هاد السيمانة",
"NO_HISTORY": "ما تلقاو حتى عناصر لهاد الفلتر.",
"NO_HISTORY_HINT": "سجل المشاهدات ديالك غادي يبان هنا منين تبدا تشغل محتوى من قوائم التشغيل ديالك.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "У трэндзе на гэтым тыдні (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Паказваць фільмы і серыялы тыдня з TMDB, супастаўленыя з вашымі бібліятэкамі. Патрабуе ўключаных метаданых TMDB.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "Паказаць усё ({{count}})",
"RECENTLY_WATCHED": "Нядаўна прагледжаныя",
"XTREAM_RECENTLY_ADDED": "Нядаўна дададзеныя на Xtream",
"TMDB_TRENDING": "У трэндзе на гэтым тыдні",
"NO_HISTORY": "Не знойдзена элементаў для гэтага фільтра.",
"NO_HISTORY_HINT": "Гісторыя прагляду з'явіцца тут, як толькі вы пачнеце прайграваць кантэнт з вашых плэйлістоў.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Zeigt favorisierte Filme und Serien als Cover-Karten.",
"DASHBOARD_RAIL_SOURCES": "Zuletzt verwendete Quellen",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Zeigt Schnellzugriffe auf zuletzt verwendete Playlists und Portale.",
"DASHBOARD_RAIL_TMDB_TRENDING": "Diese Woche im Trend (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Wöchentliche Trend-Filme und -Serien von TMDB anzeigen, abgeglichen mit Ihren Bibliotheken. Erfordert aktivierte TMDB-Metadaten.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Neu auf Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Zeigt die neuesten Xtream-Filme, Serien und Kanäle.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "Alle {{count}} anzeigen",
"RECENTLY_WATCHED": "Zuletzt angesehen",
"XTREAM_RECENTLY_ADDED": "Neu auf Xtream",
"TMDB_TRENDING": "Diese Woche im Trend",
"NO_HISTORY": "Keine Elemente für diesen Filter gefunden.",
"NO_HISTORY_HINT": "Dein Wiedergabeverlauf erscheint hier, sobald du Inhalte aus deinen Playlists abspielst.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "Δημοφιλή αυτής της εβδομάδας (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Εμφάνιση δημοφιλών ταινιών και σειρών της εβδομάδας από το TMDB, αντιστοιχισμένων με τις βιβλιοθήκες σας. Απαιτεί ενεργά μεταδεδομένα TMDB.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "Δείτε όλα {{count}}",
"RECENTLY_WATCHED": "Πρόσφατα προβληθέντα",
"XTREAM_RECENTLY_ADDED": "Πρόσφατα προστέθηκαν στο Xtream",
"TMDB_TRENDING": "Δημοφιλή αυτής της εβδομάδας",
"NO_HISTORY": "Δεν βρέθηκαν στοιχεία για αυτό το φίλτρο.",
"NO_HISTORY_HINT": "Το ιστορικό προβολών σας θα εμφανιστεί εδώ μόλις ξεκινήσετε να αναπαράγετε περιεχόμενο από τις λίστες αναπαραγωγής σας.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "Trending this week (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Show weekly trending movies and series from TMDB, matched against your libraries. Requires TMDB metadata to be enabled.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "See all {{count}}",
"RECENTLY_WATCHED": "Recently Watched",
"XTREAM_RECENTLY_ADDED": "Recently Added on Xtream",
"TMDB_TRENDING": "Trending this week",
"NO_HISTORY": "No items found for this filter.",
"NO_HISTORY_HINT": "Your watching history will appear here once you start playing content from your playlists.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "Tendencias de la semana (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Mostrar películas y series en tendencia semanal de TMDB, cotejadas con tus bibliotecas. Requiere activar los metadatos de TMDB.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "Ver todos ({{count}})",
"RECENTLY_WATCHED": "Vistos recientemente",
"XTREAM_RECENTLY_ADDED": "Añadidos recientemente en Xtream",
"TMDB_TRENDING": "Tendencias de la semana",
"NO_HISTORY": "No se encontraron elementos para este filtro.",
"NO_HISTORY_HINT": "Tu historial de visualización aparecerá aquí cuando empieces a reproducir contenido de tus listas.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "Tendances de la semaine (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Afficher les films et séries en tendance hebdomadaire de TMDB, mis en correspondance avec vos bibliothèques. Nécessite l'activation des métadonnées TMDB.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "Voir les {{count}}",
"RECENTLY_WATCHED": "Récemment regardés",
"XTREAM_RECENTLY_ADDED": "Récemment ajoutés sur Xtream",
"TMDB_TRENDING": "Tendances de la semaine",
"NO_HISTORY": "Aucun élément trouvé pour ce filtre.",
"NO_HISTORY_HINT": "Votre historique de visionnage apparaîtra ici une fois que vous aurez commencé à lire du contenu depuis vos listes de lecture.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "Tendenze della settimana (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Mostra film e serie in tendenza settimanale da TMDB, abbinati alle tue librerie. Richiede i metadati TMDB attivi.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "Vedi tutti ({{count}})",
"RECENTLY_WATCHED": "Visti di recente",
"XTREAM_RECENTLY_ADDED": "Aggiunti di recente su Xtream",
"TMDB_TRENDING": "Tendenze della settimana",
"NO_HISTORY": "Nessun elemento corrisponde a questo filtro.",
"NO_HISTORY_HINT": "La cronologia di visualizzazione apparirà qui non appena inizi a riprodurre contenuti dalle tue playlist.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "今週のトレンド(TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "TMDB の週間トレンドの映画やシリーズをライブラリと照合して表示します。TMDB メタデータの有効化が必要です。",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "すべて表示 ({{count}})",
"RECENTLY_WATCHED": "最近視聴した項目",
"XTREAM_RECENTLY_ADDED": "Xtreamに最近追加された項目",
"TMDB_TRENDING": "今週のトレンド",
"NO_HISTORY": "このフィルターに該当する項目はありません。",
"NO_HISTORY_HINT": "プレイリストからコンテンツの再生を開始すると、視聴履歴がここに表示されます。",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "이번 주 트렌드 (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "TMDB의 주간 트렌드 영화와 시리즈를 라이브러리와 대조하여 표시합니다. TMDB 메타데이터 활성화가 필요합니다.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "{{count}}개 모두 보기",
"RECENTLY_WATCHED": "최근에 시청한 항목",
"XTREAM_RECENTLY_ADDED": "Xtream에 최근 추가됨",
"TMDB_TRENDING": "이번 주 트렌드",
"NO_HISTORY": "이 필터에 해당하는 항목이 없습니다.",
"NO_HISTORY_HINT": "재생목록에서 콘텐츠를 재생하기 시작하면 시청 기록이 여기에 표시됩니다.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "Deze week trending (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Toon wekelijkse trending films en series van TMDB, vergeleken met uw bibliotheken. Vereist ingeschakelde TMDB-metadata.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "Alle {{count}} bekijken",
"RECENTLY_WATCHED": "Recent bekeken",
"XTREAM_RECENTLY_ADDED": "Recent toegevoegd op Xtream",
"TMDB_TRENDING": "Deze week trending",
"NO_HISTORY": "Geen items gevonden voor dit filter.",
"NO_HISTORY_HINT": "Je kijkgeschiedenis verschijnt hier zodra je inhoud uit je afspeellijsten begint af te spelen.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "W trendach w tym tygodniu (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Pokazuj cotygodniowe popularne filmy i seriale z TMDB dopasowane do Twoich bibliotek. Wymaga włączonych metadanych TMDB.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "Zobacz wszystkie ({{count}})",
"RECENTLY_WATCHED": "Ostatnio oglądane",
"XTREAM_RECENTLY_ADDED": "Ostatnio dodane w Xtream",
"TMDB_TRENDING": "W trendach w tym tygodniu",
"NO_HISTORY": "Brak elementów dla tego filtra.",
"NO_HISTORY_HINT": "Twoja historia oglądania pojawi się tutaj, gdy zaczniesz odtwarzać treści z list odtwarzania.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "Tendências da semana (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Mostrar filmes e séries em tendência semanal do TMDB, correspondidos com as suas bibliotecas. Requer metadados TMDB ativados.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "Ver todos ({{count}})",
"RECENTLY_WATCHED": "Assistidos recentemente",
"XTREAM_RECENTLY_ADDED": "Adicionados recentemente no Xtream",
"TMDB_TRENDING": "Tendências da semana",
"NO_HISTORY": "Nenhum item encontrado para este filtro.",
"NO_HISTORY_HINT": "Seu histórico aparecerá aqui assim que você começar a reproduzir conteúdo das suas playlists.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Показывать избранные фильмы и сериалы в виде обложек.",
"DASHBOARD_RAIL_SOURCES": "Недавно использованные источники",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Показывать быстрый доступ к недавно использованным плейлистам и порталам.",
"DASHBOARD_RAIL_TMDB_TRENDING": "В тренде на этой неделе (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Показывать фильмы и сериалы недели из TMDB, сопоставленные с вашими библиотеками. Требуются включённые метаданные TMDB.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Недавно добавленное на Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Показывать новые фильмы, сериалы и каналы Xtream.",
"APP_UPDATE_TITLE": "Обновление приложения",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "Смотреть все {{count}}",
"RECENTLY_WATCHED": "Недавно просмотренные",
"XTREAM_RECENTLY_ADDED": "Недавно добавленные на Xtream",
"TMDB_TRENDING": "В тренде на этой неделе",
"NO_HISTORY": "Для этого фильтра элементы не найдены.",
"NO_HISTORY_HINT": "История просмотра появится здесь, как только вы начнете воспроизводить контент из своих плейлистов.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "Bu hafta trend olanlar (TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "TMDB'nin haftalık trend film ve dizilerini kitaplıklarınızla eşleştirerek gösterir. TMDB meta verilerinin etkin olması gerekir.",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "{{count}} öğenin tümünü gör",
"RECENTLY_WATCHED": "Son İzlenenler",
"XTREAM_RECENTLY_ADDED": "Xtream'de Yeni Eklenenler",
"TMDB_TRENDING": "Bu hafta trend olanlar",
"NO_HISTORY": "Bu filtre için öğe bulunamadı.",
"NO_HISTORY_HINT": "Oynatma listelerinizden içerik oynatmaya başladığınızda izleme geçmişiniz burada görünecektir.",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "本周热门(TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "显示 TMDB 每周热门电影和剧集,并与您的媒体库匹配。需要启用 TMDB 元数据。",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "查看全部 {{count}} 个",
"RECENTLY_WATCHED": "最近观看",
"XTREAM_RECENTLY_ADDED": "Xtream 最近添加",
"TMDB_TRENDING": "本周热门",
"NO_HISTORY": "没有项目匹配此筛选。",
"NO_HISTORY_HINT": "开始播放播放列表中的内容后,您的观看历史将显示在此处。",
"XTREAM": "Xtream",
+3
View File
@@ -410,6 +410,8 @@
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
"DASHBOARD_RAIL_TMDB_TRENDING": "本週熱門(TMDB)",
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "顯示 TMDB 每週熱門電影和影集,並與您的媒體庫比對。需要啟用 TMDB 中繼資料。",
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
"APP_UPDATE_TITLE": "Application update",
@@ -1025,6 +1027,7 @@
"SEE_ALL_COUNT": "查看全部 {{count}} 個",
"RECENTLY_WATCHED": "最近觀看",
"XTREAM_RECENTLY_ADDED": "Xtream 最近新增",
"TMDB_TRENDING": "本週熱門",
"NO_HISTORY": "找不到符合此篩選條件的項目。",
"NO_HISTORY_HINT": "從您的播放清單開始播放內容後,您的觀看記錄就會出現在此處。",
"XTREAM": "Xtream",
@@ -46,6 +46,7 @@ store imports):
| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups |
| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:<id>` rows) |
| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:<id>\|season:<n>` rows) |
| `tmdb-trending.service.ts` | Weekly trending (movie + tv merged by popularity, `trending:week` rows, 1-day TTL) |
Integration glue per portal:
@@ -245,3 +246,27 @@ never blocks or delays rendering of the detail view.
Similar/recommendations rails, actor cross-catalog search, trending
dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache
(IndexedDB).
## Dashboard Integration
- **Trending rail** ("Trending this week", `dashboardRails.tmdbTrending`
toggle): `DashboardTrendingService`
(`libs/workspace/dashboard/data-access`) pulls the weekly TMDB trending
lists (cached one day per language) and runs ONE batched
`CatalogTitleMatchService.matchTitles` request against the imported
Xtream playlists — matched cards navigate straight to their detail view
and show the playlist name; unmatched cards open the global search
prefilled. Requires both the TMDB opt-in and the Electron DB worker
(the rail is hidden in the PWA). The load fires only after the
dashboard's own recent/favorites data is in, so it never competes for
the worker at startup, and runs once per app session.
- **Hero extras**: `DashboardHeroTmdbService`
(`libs/workspace/dashboard/feature`) patches the hero card with a TMDB
backdrop (when the provider item has none), a rating badge and up to two
genre chips — resolved through the enrichment facade, so items already
opened in a detail view come from the SQLite cache without network.
Results are memoized per title for the session. The hero renders
immediately from provider data; extras appear when resolved. Series
heroes additionally show the tracked "S{n}·E{n}" badge from the playback
position (no TMDB involved); the watch-progress bar is limited to
movie/series heroes.
@@ -23,6 +23,7 @@ const expectedDashboardRails = (
favoriteMoviesAndSeries: true,
recentSources: true,
xtreamRecentlyAdded: true,
tmdbTrending: true,
...overrides,
});
@@ -76,6 +76,20 @@ export class TmdbApiService {
);
}
/** Weekly trending titles for one media type */
async getTrending(
mediaType: 'movie' | 'tv',
language: string,
apiKey: string
): Promise<TmdbSearchResult[]> {
const response = await this.request<TmdbSearchResponse>(
`/trending/${mediaType}/week`,
{ language },
apiKey
);
return response.results ?? [];
}
async getPersonDetails(
personId: number,
language: string,
@@ -26,6 +26,9 @@ export const TMDB_MATCH_CACHE_TTL_MS = 30 * DAY_MS;
/** Negative matches retry sooner — providers fix titles, TMDB adds entries */
export const TMDB_NEGATIVE_MATCH_CACHE_TTL_MS = 7 * DAY_MS;
/** Weekly trending rotates slowly — refresh the dashboard rail daily */
export const TMDB_TRENDING_CACHE_TTL_MS = 1 * DAY_MS;
/** App language → TMDB language-region code for localized metadata */
const TMDB_LANGUAGE_MAP: Record<Language, string> = {
[Language.ARABIC]: 'ar-SA',
@@ -19,6 +19,7 @@ import {
import { TmdbPersonService } from './tmdb-person.service';
import { TmdbRuntimeService } from './tmdb-runtime.service';
import { TmdbSeasonService } from './tmdb-season.service';
import { TmdbTrendingService } from './tmdb-trending.service';
import {
TmdbDetails,
TmdbEnrichmentQuery,
@@ -26,6 +27,7 @@ import {
TmdbMovieDetails,
TmdbPersonDetails,
TmdbSeasonDetails,
TmdbTrendingEntry,
TmdbTvDetails,
} from './tmdb.types';
@@ -46,6 +48,7 @@ export class TmdbEnrichmentService {
private readonly cache = inject(TmdbCacheService);
private readonly person = inject(TmdbPersonService);
private readonly season = inject(TmdbSeasonService);
private readonly trending = inject(TmdbTrendingService);
isEnabled(): boolean {
return this.runtime.isEnabled();
@@ -80,6 +83,11 @@ export class TmdbEnrichmentService {
return this.season.getSeason(tmdbId, seasonNumber);
}
/** Weekly trending titles (movies + series merged by popularity) */
async getTrendingWeek(limit?: number): Promise<TmdbTrendingEntry[]> {
return this.trending.getTrendingWeek(limit);
}
async getPersonDetails(
personId: number
): Promise<TmdbPersonDetails | null> {
@@ -0,0 +1,110 @@
import { Injectable, inject } from '@angular/core';
import { TmdbApiService } from './tmdb-api.service';
import { TmdbCacheService } from './tmdb-cache.service';
import {
TMDB_TRENDING_CACHE_TTL_MS,
tmdbPosterUrl,
} from './tmdb-config';
import { extractYear } from './tmdb-matcher';
import { TmdbRuntimeService } from './tmdb-runtime.service';
import { TmdbSearchResult, TmdbTrendingEntry } from './tmdb.types';
/**
* Weekly TMDB trending for the dashboard rail. Movie and TV lists are
* fetched separately (one request each, cached for a day per language)
* and merged by popularity.
*/
@Injectable({ providedIn: 'root' })
export class TmdbTrendingService {
private readonly runtime = inject(TmdbRuntimeService);
private readonly api = inject(TmdbApiService);
private readonly cache = inject(TmdbCacheService);
async getTrendingWeek(limit = 18): Promise<TmdbTrendingEntry[]> {
if (!this.runtime.isEnabled()) {
return [];
}
const [movies, tv] = await Promise.all([
this.getTrendingFor('movie'),
this.getTrendingFor('tv'),
]);
return [...movies, ...tv]
.sort((a, b) => b.popularity - a.popularity)
.slice(0, limit);
}
private async getTrendingFor(
mediaType: 'movie' | 'tv'
): Promise<TmdbTrendingEntry[]> {
try {
const language = this.runtime.language();
const lookupKey = 'trending:week';
const cached = await this.cache.get(
mediaType,
lookupKey,
language
);
if (
this.cache.isFresh(cached, TMDB_TRENDING_CACHE_TTL_MS) &&
cached?.payload
) {
try {
return this.toEntries(
JSON.parse(cached.payload) as TmdbSearchResult[],
mediaType
);
} catch {
// Corrupt cache row — fall through to a fresh fetch
}
}
const results = await this.api.getTrending(
mediaType,
language,
this.runtime.apiKey()
);
await this.cache.set({
mediaType,
lookupKey,
language,
tmdbId: null,
payload: JSON.stringify(results),
});
return this.toEntries(results, mediaType);
} catch (error) {
console.warn(`TMDB trending (${mediaType}) failed:`, error);
return [];
}
}
private toEntries(
results: TmdbSearchResult[],
mediaType: 'movie' | 'tv'
): TmdbTrendingEntry[] {
return results
.map((result) => {
const title = result.title ?? result.name ?? '';
const rating =
(result.vote_count ?? 0) > 0 && result.vote_average
? result.vote_average.toFixed(1)
: null;
return {
tmdbId: result.id,
mediaType,
title,
year: extractYear(
result.release_date ?? result.first_air_date
),
posterUrl: tmdbPosterUrl(result.poster_path),
rating,
popularity: result.popularity ?? 0,
};
})
.filter((entry) => entry.tmdbId > 0 && entry.title !== '');
}
}
+13
View File
@@ -16,9 +16,22 @@ export interface TmdbSearchResult {
first_air_date?: string;
popularity?: number;
vote_count?: number;
vote_average?: number;
poster_path?: string | null;
}
/** One dashboard-ready trending title (movie or series) */
export interface TmdbTrendingEntry {
tmdbId: number;
mediaType: 'movie' | 'tv';
title: string;
year: number | null;
posterUrl: string | null;
/** vote_average rounded to one decimal, null without votes */
rating: string | null;
popularity: number;
}
export interface TmdbSearchResponse {
results?: TmdbSearchResult[];
}
@@ -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<
@@ -1 +1,2 @@
export * from './lib/dashboard-data.service';
export * from './lib/dashboard-trending.service';
@@ -0,0 +1,118 @@
import { TestBed } from '@angular/core/testing';
import {
CatalogTitleMatchService,
TmdbEnrichmentService,
} from '@iptvnator/services';
import type { TmdbTrendingEntry } from '@iptvnator/services';
import { CatalogTitleMatch } from '@iptvnator/shared/interfaces';
import { DashboardTrendingService } from './dashboard-trending.service';
describe('DashboardTrendingService', () => {
const entry = (
overrides: Partial<TmdbTrendingEntry> = {}
): TmdbTrendingEntry => ({
tmdbId: 603,
mediaType: 'movie',
title: 'The Matrix',
year: 1999,
posterUrl: null,
rating: '8.2',
popularity: 100,
...overrides,
});
const match = (
overrides: Partial<CatalogTitleMatch> = {}
): CatalogTitleMatch => ({
queryTitle: 'The Matrix',
playlistId: 'pl-1',
playlistName: 'My Portal',
categoryId: 7,
xtreamId: 42,
type: 'movie',
trailingYear: null,
...overrides,
});
let getTrendingWeek: jest.Mock;
let matchTitles: jest.Mock;
let isEnabled: jest.Mock;
function createService(
options: { matchingAvailable?: boolean } = {}
): DashboardTrendingService {
TestBed.configureTestingModule({
providers: [
{
provide: TmdbEnrichmentService,
useValue: { isEnabled, getTrendingWeek },
},
{
provide: CatalogTitleMatchService,
useValue: {
isAvailable: options.matchingAvailable ?? true,
matchTitles,
},
},
],
});
return TestBed.inject(DashboardTrendingService);
}
beforeEach(() => {
isEnabled = jest.fn().mockReturnValue(true);
getTrendingWeek = jest.fn().mockResolvedValue([entry()]);
matchTitles = jest.fn().mockResolvedValue([match()]);
});
it('does nothing when TMDB is disabled', async () => {
isEnabled.mockReturnValue(false);
const service = createService();
await service.load();
expect(getTrendingWeek).not.toHaveBeenCalled();
expect(service.items()).toEqual([]);
});
it('does nothing without the Electron title matcher (PWA)', async () => {
const service = createService({ matchingAvailable: false });
await service.load();
expect(getTrendingWeek).not.toHaveBeenCalled();
});
it('attaches library matches to trending entries', async () => {
const service = createService();
await service.load();
expect(service.items()).toHaveLength(1);
expect(service.items()[0].match?.playlistName).toBe('My Portal');
expect(service.loading()).toBe(false);
});
it('rejects year-incompatible base-tier matches', async () => {
getTrendingWeek.mockResolvedValue([
entry({ title: 'Blade Runner', year: 1982 }),
]);
matchTitles.mockResolvedValue([
match({ queryTitle: 'Blade Runner', trailingYear: 2049 }),
]);
const service = createService();
await service.load();
expect(service.items()[0].match).toBeNull();
});
it('loads only once per session', async () => {
const service = createService();
await service.load();
await service.load();
expect(getTrendingWeek).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,84 @@
import { Injectable, inject, signal } from '@angular/core';
import {
CatalogTitleMatchService,
TmdbEnrichmentService,
buildTitleMatchIndex,
} from '@iptvnator/services';
import {
CatalogTitleMatch,
normalizeTitleKeys,
titleYearsCompatible,
} from '@iptvnator/shared/interfaces';
import type { TmdbTrendingEntry } from '@iptvnator/services';
/** One trending card: TMDB entry + optional library match for navigation */
export interface DashboardTrendingItem extends TmdbTrendingEntry {
/** Confident match in an imported Xtream playlist, when found */
match: CatalogTitleMatch | null;
}
/**
* "Trending this week" dashboard rail data. Requires the TMDB opt-in and
* (for availability matching / navigation) the Electron DB worker — the
* rail is hidden in the PWA. Trending lists are cached for a day, and the
* title match runs as ONE batched worker request, fired only after the
* dashboard's own data has loaded so it never competes for the worker.
*/
@Injectable({ providedIn: 'root' })
export class DashboardTrendingService {
private readonly enrichment = inject(TmdbEnrichmentService);
private readonly titleMatch = inject(CatalogTitleMatchService);
readonly items = signal<DashboardTrendingItem[]>([]);
readonly loading = signal(false);
private loadedOnce = false;
get isAvailable(): boolean {
return this.enrichment.isEnabled() && this.titleMatch.isAvailable;
}
/** Idempotent per app session — dashboard revisits reuse the signal */
async load(): Promise<void> {
if (this.loadedOnce || !this.isAvailable) {
return;
}
this.loadedOnce = true;
this.loading.set(true);
try {
const entries = await this.enrichment.getTrendingWeek();
if (entries.length === 0) {
return;
}
const matches = await this.titleMatch.matchTitles(
entries.map((entry) => entry.title)
);
const index = buildTitleMatchIndex(matches);
this.items.set(
entries.map((entry) => ({
...entry,
match: this.matchFor(entry, index),
}))
);
} catch (error) {
console.warn('Dashboard trending load failed:', error);
} finally {
this.loading.set(false);
}
}
private matchFor(
entry: TmdbTrendingEntry,
index: ReadonlyMap<string, CatalogTitleMatch>
): CatalogTitleMatch | null {
const type = entry.mediaType === 'movie' ? 'movie' : 'series';
const key = `${type}:${normalizeTitleKeys(entry.title).exact}`;
const match = index.get(key) ?? null;
return match && titleYearsCompatible(entry.year, match.trailingYear)
? match
: null;
}
}
@@ -0,0 +1,88 @@
import { Injectable, inject } from '@angular/core';
import {
TmdbEnrichmentService,
extractYear,
tmdbBackdropUrl,
} from '@iptvnator/services';
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
/** TMDB extras for the dashboard hero, patched in after first paint */
export interface DashboardHeroTmdbExtras {
readonly backdropUrl: string | null;
readonly rating: string | null;
readonly genres: readonly string[];
}
const MAX_HERO_GENRES = 2;
/**
* Best-effort TMDB extras for the hero card (backdrop, rating, genres).
* Goes through the enrichment facade, so items already opened in a detail
* view resolve from the SQLite cache without network. Results are memoized
* per title for the session — dashboard revisits skip the IPC round-trip.
*/
@Injectable({ providedIn: 'root' })
export class DashboardHeroTmdbService {
private readonly enrichment = inject(TmdbEnrichmentService);
private readonly memo = new Map<
string,
Promise<DashboardHeroTmdbExtras | null>
>();
getExtras(
item: Pick<GlobalRecentItem, 'title' | 'type'>
): Promise<DashboardHeroTmdbExtras | null> {
if (
!this.enrichment.isEnabled() ||
(item.type !== 'movie' && item.type !== 'series')
) {
return Promise.resolve(null);
}
const key = `${item.type}:${item.title}`;
const cached = this.memo.get(key);
if (cached) {
return cached;
}
const pending = this.loadExtras(item);
this.memo.set(key, pending);
return pending;
}
private async loadExtras(
item: Pick<GlobalRecentItem, 'title' | 'type'>
): Promise<DashboardHeroTmdbExtras | null> {
try {
const query = {
title: item.title,
year: extractYear(null, item.title),
};
const details =
item.type === 'movie'
? await this.enrichment.enrichMovie(query)
: await this.enrichment.enrichTv(query);
if (!details) {
return null;
}
const rating =
(details.vote_count ?? 0) > 0 && details.vote_average
? details.vote_average.toFixed(1)
: null;
const genres = (details.genres ?? [])
.map((genre) => genre.name)
.filter(Boolean)
.slice(0, MAX_HERO_GENRES);
return {
backdropUrl: tmdbBackdropUrl(details.backdrop_path),
rating,
genres,
};
} catch (error) {
console.warn('Dashboard hero TMDB extras failed:', error);
return null;
}
}
}
@@ -14,6 +14,12 @@ export interface DashboardHeroModel {
readonly state?: Record<string, unknown>;
readonly subtitle: string;
readonly title: string;
/** TMDB vote average ("8.1"), patched in async when available */
readonly rating?: string | null;
/** Up to two TMDB genre names, patched in async when available */
readonly genres?: readonly string[];
/** "S2·E5" for series with a tracked episode position */
readonly episodeBadge?: string | null;
/** 0-100 watched, when a resume position is known. */
readonly watchProgress?: number | null;
readonly remainingLabel?: DashboardRemainingLabel | null;
@@ -66,6 +66,7 @@
<a
class="rail__channel-link"
[routerLink]="card.link"
[queryParams]="card.queryParams ?? null"
[state]="card.state"
>
<div class="rail__channel-row">
@@ -141,6 +142,7 @@
<a
class="rail__card-link"
[routerLink]="card.link"
[queryParams]="card.queryParams ?? null"
[state]="card.state"
>
<div
@@ -33,6 +33,7 @@ export interface DashboardRailCard {
icon: string;
contentType?: 'live' | 'movie' | 'series';
link: string[];
queryParams?: Record<string, string>;
state?: Record<string, unknown>;
actions?: DashboardRailAction[];
epgLookupKey?: string;
@@ -77,6 +77,35 @@
<h1 class="hero__title">{{ item.title }}</h1>
<p class="hero__subtitle">{{ item.subtitle }}</p>
@if (
item.episodeBadge ||
item.rating ||
(item.genres && item.genres.length > 0)
) {
<span
class="hero__badges"
data-test-id="dashboard-hero-badges"
>
@if (item.episodeBadge) {
<span
class="hero__badge hero__badge--episode"
>{{ item.episodeBadge }}</span
>
}
@if (item.rating) {
<span
class="hero__badge hero__badge--rating"
>★ {{ item.rating }}</span
>
}
@for (genre of item.genres; track genre) {
<span class="hero__badge">{{
genre
}}</span>
}
</span>
}
@if (
item.contentType === 'live' &&
(item.nowPlayingTitle ||
@@ -325,5 +354,30 @@
</div>
</section>
}
<!-- TMDB trending rail (opt-in; Electron only — needs title matching) -->
@if (dashboardRails().tmdbTrending && trendingCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.TMDB_TRENDING' | translate"
[items]="trendingCards()"
[totalCount]="trendingCards().length"
[testId]="'dashboard-tmdb-trending-rail'"
/>
} @else if (
dashboardRails().tmdbTrending && trendingService.loading()
) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
}
</section>
@@ -295,6 +295,50 @@
}
}
.hero__badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.hero__badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--app-body-color, #8b93a8);
background: rgba(128, 138, 160, 0.14);
border: 1px solid rgba(128, 138, 160, 0.22);
.hero--with-image & {
color: rgba(255, 255, 255, 0.88);
background: rgba(0, 0, 0, 0.35);
border-color: rgba(255, 255, 255, 0.18);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
&--rating {
color: #ffd166;
.hero--with-image & {
color: #ffd166;
}
}
&--episode {
color: #7ddb8a;
.hero--with-image & {
color: #7ddb8a;
}
}
}
.hero__live-epg {
display: flex;
flex-direction: column;
@@ -41,8 +41,12 @@ import {
DashboardDataService,
DashboardFavoriteItem,
DashboardRecentlyAddedItem,
DashboardTrendingItem,
DashboardTrendingService,
GlobalRecentItem,
} from '@iptvnator/workspace/dashboard/data-access';
import type { DashboardHeroTmdbExtras } from './dashboard-hero-tmdb.service';
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
import { DashboardRailComponent } from './dashboard-rail.component';
import type {
DashboardRailCard,
@@ -115,6 +119,8 @@ export class WorkspaceDashboardRailsComponent {
private readonly epgService = inject(EpgService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly heroTmdb = inject(DashboardHeroTmdbService);
readonly trendingService = inject(DashboardTrendingService);
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
readonly ready = this.data.dashboardReady;
@@ -133,6 +139,12 @@ export class WorkspaceDashboardRailsComponent {
() => this.data.globalRecentItems()[0] ?? null
);
/** TMDB extras for the current hero item, patched in after first paint */
private readonly heroTmdbExtras = signal<{
key: string;
extras: DashboardHeroTmdbExtras | null;
} | null>(null);
private readonly heroLiveCard = computed<DashboardRailCard | null>(() => {
const item = this.heroRecentItem();
return item?.type === 'live' ? this.toRecentCard(item) : null;
@@ -144,9 +156,14 @@ export class WorkspaceDashboardRailsComponent {
return null;
}
const extrasForArtwork = this.heroTmdbExtras();
const tmdbBackdrop =
extrasForArtwork?.key === this.heroTmdbKey(item)
? (extrasForArtwork.extras?.backdropUrl ?? null)
: null;
const artwork = resolveDashboardHeroArtwork(
{
backdropUrl: item.backdrop_url,
backdropUrl: item.backdrop_url || tmdbBackdrop,
posterUrl: item.poster_url,
title: item.title,
},
@@ -158,6 +175,23 @@ export class WorkspaceDashboardRailsComponent {
item.type === 'live'
? this.getLiveEpgDetailsForCard(this.heroLiveCard())
: null;
const extrasState = this.heroTmdbExtras();
const extras =
extrasState?.key === this.heroTmdbKey(item)
? extrasState.extras
: null;
const episodeBadge =
item.type === 'series' &&
position?.seasonNumber != null &&
position?.episodeNumber != null
? this.translate.instant(
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
{
season: position.seasonNumber,
episode: position.episodeNumber,
}
)
: null;
return {
...artwork,
@@ -167,8 +201,13 @@ export class WorkspaceDashboardRailsComponent {
state: this.data.getRecentItemNavigationState(item),
subtitle: this.buildHeroSubtitle(item),
title: item.title,
watchProgress: playbackProgressPercent(position),
remainingLabel: formatRemainingLabel(position),
rating: extras?.rating ?? null,
genres: extras?.genres ?? [],
episodeBadge,
watchProgress:
item.type === 'live' ? null : playbackProgressPercent(position),
remainingLabel:
item.type === 'live' ? null : formatRemainingLabel(position),
nowPlayingTitle: liveEpgDetails?.nowPlayingTitle ?? null,
nowPlayingTimeRange: liveEpgDetails?.nowPlayingTimeRange ?? null,
nowPlayingProgress: liveEpgDetails?.nowPlayingProgress ?? null,
@@ -289,6 +328,13 @@ export class WorkspaceDashboardRailsComponent {
.map((item) => this.toRecentlyAddedCard(item))
);
readonly trendingCards = computed<DashboardRailCard[]>(() => {
this.languageTick();
return this.trendingService
.items()
.map((item) => this.toTrendingCard(item));
});
readonly sourceCards = computed<DashboardRailCard[]>(() =>
this.data.recentPlaylists().map((playlist) => ({
id: playlist._id,
@@ -338,6 +384,40 @@ export class WorkspaceDashboardRailsComponent {
this.playbackPositionReloadKey();
untracked(() => void this.data.reloadPlaybackPositions());
});
// TMDB extras for the hero (backdrop/rating/genres) — async after
// first paint, staleness-guarded against hero changes in flight.
effect(() => {
const item = this.heroRecentItem();
if (!item || (item.type !== 'movie' && item.type !== 'series')) {
return;
}
const key = this.heroTmdbKey(item);
untracked(() => {
if (this.heroTmdbExtras()?.key === key) {
return;
}
void this.heroTmdb.getExtras(item).then((extras) => {
const current = untracked(() => this.heroRecentItem());
if (current && this.heroTmdbKey(current) === key) {
this.heroTmdbExtras.set({ key, extras });
}
});
});
});
// Trending rail: needs the TMDB opt-in and the Electron DB worker.
// Deferred until the dashboard's own recent/favorites data is in so
// the batched title match never competes for the worker at startup.
effect(() => {
if (
!this.dashboardRails().tmdbTrending ||
!this.data.globalFavoritesLoaded()
) {
return;
}
untracked(() => void this.trendingService.load());
});
}
onAddPlaylist(type?: WorkspacePlaylistType): void {
@@ -481,6 +561,38 @@ export class WorkspaceDashboardRailsComponent {
};
}
private heroTmdbKey(item: GlobalRecentItem): string {
return `${item.type}:${item.title}`;
}
private toTrendingCard(item: DashboardTrendingItem): DashboardRailCard {
const subtitle = [
item.year !== null ? String(item.year) : null,
item.rating ? `★ ${item.rating}` : null,
item.match?.playlistName ?? null,
]
.filter((value): value is string => Boolean(value))
.join(' · ');
return {
id: `trending-${item.mediaType}-${item.tmdbId}`,
title: item.title,
subtitle,
imageUrl: item.posterUrl ?? undefined,
icon: item.mediaType === 'movie' ? 'movie' : 'video_library',
contentType: item.mediaType === 'movie' ? 'movie' : 'series',
link: item.match
? [
'/workspace/xtreams',
item.match.playlistId,
item.match.type === 'movie' ? 'vod' : 'series',
String(item.match.categoryId),
String(item.match.xtreamId),
]
: ['/workspace/search'],
...(item.match ? {} : { queryParams: { q: item.title } }),
};
}
private typeIcon(type: 'live' | 'movie' | 'series'): string {
if (type === 'live') return 'live_tv';
if (type === 'movie') return 'movie';