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

+2 -1
View File
@@ -645,8 +645,9 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
**TMDB Metadata Enrichment** (opt-in):
- 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
- "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`)
- 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",
+36 -3
View File
@@ -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:
@@ -123,9 +124,17 @@ year tag is compatible (±1) with the TMDB year, so "Blade Runner" (1982)
never claims a catalog "Blade Runner 2049". The rail navigates
to the matched item — the detail components re-initialize on route param
changes (reactive `routeParams` signal) because the router reuses the
component for detail→detail navigation. Stalker gets trailers and the
`tmdb_recommendations` data, but no rail yet (its catalog is
server-paginated, so there is no local list to match against).
component for detail→detail navigation.
`CrossPortalSimilarService` (`libs/services`) extends the rail across
portals: recommendations are matched against ALL imported Xtream
playlists with one batched `DB_MATCH_TITLES` request (Electron only,
same two-tier + year rule). Stalker detail views — where the local
catalog is server-paginated and unmatchable — get their "Similar" rail
purely from these cross-portal matches (shared `VodDetailsComponent` for
movies, `stalker-series-view` for series); Xtream detail views append
them after the local-catalog matches, deduplicated by normalized title,
with the source playlist name on each card.
The `language` param derives from the app language setting
(`Language` enum → TMDB code, e.g. `de` → `de-DE`); cache rows are keyed per
language, so switching the app language re-fetches localized metadata.
@@ -262,3 +271,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.
@@ -166,6 +166,53 @@
}
<div detail-extras>
@if (similarInPortals().length) {
<section
class="details__similar"
data-test-id="tmdb-similar"
>
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (
item of similarInPortals();
track item.match.xtreamId
) {
<button
type="button"
class="details__similar-card"
[title]="
item.title +
' — ' +
item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
<span class="details__similar-source">
{{ item.match.playlistName }}
</span>
</button>
}
</div>
</section>
}
<!-- YouTube Trailer (TMDB enrichment; Stalker provides none) -->
@if (trailerEmbedUrl(); as trailerUrl) {
<div class="youtube-trailer">
@@ -62,6 +62,8 @@ import {
type SeriesPlaybackEpisodeState,
} from '@iptvnator/ui/playback';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
DownloadsService,
PlaybackPositionRuntimeBridgeService,
} from '@iptvnator/services';
@@ -128,6 +130,38 @@ export class StalkerSeriesViewComponent implements OnDestroy {
readonly selectedItem = this.stalkerStore.selectedItem;
private readonly tmdbSeasons = inject(StalkerSeriesTmdbSeasonsService);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
/**
* TMDB recommendations found in the user's Xtream portals (batched DB
* match, Electron only) — Stalker catalogs are server-paginated, so
* "Similar" can only point at OTHER portals' libraries.
*/
readonly similarInPortals = signal<CrossPortalSimilarItem[]>([]);
private readonly loadSimilarInPortals = effect(() => {
const recommendations =
this.displayItem()?.info?.tmdb_recommendations;
untracked(() => {
this.similarInPortals.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'series')
.then((items) => {
if (
this.displayItem()?.info?.tmdb_recommendations ===
recommendations
) {
this.similarInPortals.set(items);
}
});
});
});
/** Season currently selected in the season container. */
private readonly selectedSeasonKey = signal<string | null>(null);
@@ -531,6 +565,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
);
}
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
}
openActor(member: TmdbEnrichedCastMember): void {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId || !member.tmdbPersonId) {
@@ -165,7 +165,7 @@
/>
<div detail-extras>
@if (similarItems().length) {
@if (similarItems().length || similarInPortals().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@@ -195,6 +195,42 @@
</span>
</button>
}
@for (
item of similarInPortals();
track 'x' + item.match.playlistId + '-' + item.match.xtreamId
) {
<button
type="button"
class="details__similar-card"
[title]="
item.title +
' — ' +
item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
<span class="details__similar-source">
{{ item.match.playlistName }}
</span>
</button>
}
</div>
</section>
}
@@ -33,7 +33,12 @@ import {
XtreamSerieInfo,
XtreamSerieEpisode,
XtreamSerieDetails,
normalizeTitleKeys,
} from '@iptvnator/shared/interfaces';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
} from '@iptvnator/services';
import {
SerialDetailsPlaybackService,
type XtreamSerieDetailsView,
@@ -76,6 +81,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
private readonly location = inject(Location);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
private readonly xtreamStore = inject(XtreamStore);
private readonly playback = inject(SerialDetailsPlaybackService);
private readonly snackBar = inject(MatSnackBar);
@@ -138,6 +144,46 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
);
});
/** Recommendations found in the user's OTHER portals (Electron only) */
private readonly crossPortalItems = signal<CrossPortalSimilarItem[]>([]);
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
const localTitles = new Set(
this.similarItems().map(
(item) => normalizeTitleKeys(item.title).exact
)
);
return this.crossPortalItems().filter(
(item) => !localTitles.has(normalizeTitleKeys(item.title).exact)
);
});
private readonly loadCrossPortalSimilar = effect(() => {
const recommendations =
this.selectedItem()?.info?.tmdb_recommendations;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
untracked(() => {
this.crossPortalItems.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'series', {
excludePlaylistId: playlistId,
})
.then((items) => {
if (
this.selectedItem()?.info?.tmdb_recommendations ===
recommendations
) {
this.crossPortalItems.set(items);
}
});
});
});
constructor() {
this.playback.bind({ selectedItem: this.selectedItem });
@@ -246,6 +292,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
this.xtreamStore.setSelectedItem(null);
}
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
}
openSimilar(item: SimilarCatalogItem): void {
void this.router.navigate(['../..', item.categoryId, item.id], {
relativeTo: this.route,
@@ -237,7 +237,7 @@
</div>
}
@if (similarItems().length) {
@if (similarItems().length || similarInPortals().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@@ -267,6 +267,42 @@
</span>
</button>
}
@for (
item of similarInPortals();
track 'x' + item.match.playlistId + '-' + item.match.xtreamId
) {
<button
type="button"
class="details__similar-card"
[title]="
item.title +
' — ' +
item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
<span class="details__similar-source">
{{ item.match.playlistName }}
</span>
</button>
}
</div>
</section>
}
@@ -8,6 +8,7 @@ import {
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
@@ -27,14 +28,20 @@ import {
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
} from '@iptvnator/ui/playback';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import {
XtreamCategory,
CrossPortalSimilarItem,
CrossPortalSimilarService,
DownloadsService,
SettingsStore,
} from '@iptvnator/services';
import {
getXtreamVodInfo,
normalizeTitleKeys,
TmdbEnrichedCastMember,
XtreamCategory,
XtreamVodDetails,
XtreamVodInfo,
XtreamVodStream,
getXtreamVodInfo,
youtubeEmbedUrl,
} from '@iptvnator/shared/interfaces';
import {
@@ -72,6 +79,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly settingsStore = inject(SettingsStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
private readonly xtreamStore = inject(XtreamStore);
private readonly downloadsService = inject(DownloadsService);
private readonly snackBar = inject(MatSnackBar);
@@ -228,6 +236,45 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
);
});
/** Recommendations found in the user's OTHER portals (Electron only) */
private readonly crossPortalItems = signal<CrossPortalSimilarItem[]>([]);
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
const localTitles = new Set(
this.similarItems().map(
(item) => normalizeTitleKeys(item.title).exact
)
);
return this.crossPortalItems().filter(
(item) => !localTitles.has(normalizeTitleKeys(item.title).exact)
);
});
private readonly loadCrossPortalSimilar = effect(() => {
const recommendations = this.selectedVodInfo()?.tmdb_recommendations;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
untracked(() => {
this.crossPortalItems.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'movie', {
excludePlaylistId: playlistId,
})
.then((items) => {
if (
this.selectedVodInfo()?.tmdb_recommendations ===
recommendations
) {
this.crossPortalItems.set(items);
}
});
});
});
constructor() {
this.playback.bind({
vodId: this.selectedVodId,
@@ -288,6 +335,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
}
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
}
openSimilar(item: SimilarCatalogItem): void {
void this.router.navigate(['../..', item.categoryId, item.id], {
relativeTo: this.route,
+1
View File
@@ -1,4 +1,5 @@
export * from './lib/catalog-title-match.service';
export * from './lib/cross-portal-similar.service';
export * from './lib/data.service';
export * from './lib/database-electron.service';
export * from './lib/downloads.service';
@@ -0,0 +1,115 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { CatalogTitleMatch } from '@iptvnator/shared/interfaces';
import { CatalogTitleMatchService } from './catalog-title-match.service';
import { CrossPortalSimilarService } from './cross-portal-similar.service';
describe('CrossPortalSimilarService', () => {
const rec = (title: string, year: number | null = 1999) => ({
tmdbId: 1,
title,
year,
posterUrl: null,
});
const match = (
overrides: Partial<CatalogTitleMatch> = {}
): CatalogTitleMatch => ({
queryTitle: 'The Matrix',
playlistId: 'pl-1',
playlistName: 'Portal One',
categoryId: 7,
xtreamId: 42,
type: 'movie',
trailingYear: null,
...overrides,
});
let matchTitles: jest.Mock;
let isAvailable: boolean;
// The services Jest target has no @angular/core/testing — construct
// the service in a plain injection context instead of TestBed
function createService(): CrossPortalSimilarService {
const injector = Injector.create({
providers: [
{
provide: CatalogTitleMatchService,
useValue: {
get isAvailable() {
return isAvailable;
},
matchTitles,
},
},
],
});
return runInInjectionContext(
injector,
() => new CrossPortalSimilarService()
);
}
beforeEach(() => {
isAvailable = true;
matchTitles = jest.fn().mockResolvedValue([match()]);
});
it('resolves to [] in the PWA', async () => {
isAvailable = false;
const service = createService();
await expect(
service.matchRecommendations([rec('The Matrix')], 'movie')
).resolves.toEqual([]);
expect(matchTitles).not.toHaveBeenCalled();
});
it('returns matched recommendations with navigation targets', async () => {
const service = createService();
const items = await service.matchRecommendations(
[rec('The Matrix'), rec('Unmatched Title')],
'movie'
);
expect(items).toHaveLength(1);
expect(items[0].match.playlistName).toBe('Portal One');
expect(service.buildLink(items[0])).toEqual([
'/workspace/xtreams',
'pl-1',
'vod',
'7',
'42',
]);
});
it('excludes the current playlist but keeps other playlists', async () => {
matchTitles.mockResolvedValue([
match({ playlistId: 'current' }),
match({ playlistId: 'other', playlistName: 'Portal Two' }),
]);
const service = createService();
const items = await service.matchRecommendations(
[rec('The Matrix')],
'movie',
{ excludePlaylistId: 'current' }
);
expect(items).toHaveLength(1);
expect(items[0].match.playlistId).toBe('other');
});
it('drops type mismatches and year-incompatible matches', async () => {
matchTitles.mockResolvedValue([
match({ type: 'series' }),
match({ queryTitle: 'Blade Runner', trailingYear: 2049 }),
]);
const service = createService();
const items = await service.matchRecommendations(
[rec('The Matrix'), rec('Blade Runner', 1982)],
'movie'
);
expect(items).toEqual([]);
});
});
@@ -0,0 +1,101 @@
import { Injectable, inject } from '@angular/core';
import {
CatalogTitleMatch,
TmdbRecommendation,
normalizeTitleKeys,
titleYearsCompatible,
} from '@iptvnator/shared/interfaces';
import {
CatalogTitleMatchService,
buildTitleMatchIndex,
} from './catalog-title-match.service';
/** One TMDB recommendation found in an imported Xtream playlist */
export interface CrossPortalSimilarItem {
title: string;
posterUrl: string | null;
year: number | null;
/** Where to navigate: playlist + category + item in that portal */
match: CatalogTitleMatch;
}
const DEFAULT_LIMIT = 12;
/**
* Matches TMDB recommendations against ALL imported Xtream playlists via
* one batched DB-worker request — powers the "Similar" rail for portals
* without a matchable local catalog (Stalker) and supplements the Xtream
* rail with titles available in the user's other portals. Electron-only:
* `isAvailable` is false in the PWA and every call resolves to [].
*/
@Injectable({ providedIn: 'root' })
export class CrossPortalSimilarService {
private readonly titleMatch = inject(CatalogTitleMatchService);
get isAvailable(): boolean {
return this.titleMatch.isAvailable;
}
async matchRecommendations(
recommendations: readonly TmdbRecommendation[] | undefined,
type: 'movie' | 'series',
options: { excludePlaylistId?: string; limit?: number } = {}
): Promise<CrossPortalSimilarItem[]> {
if (!recommendations?.length || !this.isAvailable) {
return [];
}
const matches = await this.titleMatch.matchTitles(
recommendations.map((recommendation) => recommendation.title)
);
// Filter before indexing so a title also present in another
// playlist survives the exclusion of the current one
const index = buildTitleMatchIndex(
matches.filter(
(match) =>
match.type === type &&
match.playlistId !== options.excludePlaylistId
)
);
const limit = options.limit ?? DEFAULT_LIMIT;
const seen = new Set<string>();
const items: CrossPortalSimilarItem[] = [];
for (const recommendation of recommendations) {
if (items.length >= limit) {
break;
}
const key = `${type}:${normalizeTitleKeys(recommendation.title).exact}`;
const match = index.get(key);
if (
!match ||
!titleYearsCompatible(recommendation.year, match.trailingYear)
) {
continue;
}
const dedupeKey = `${match.playlistId}:${match.type}:${match.xtreamId}`;
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
items.push({
title: recommendation.title,
posterUrl: recommendation.posterUrl,
year: recommendation.year,
match,
});
}
return items;
}
/** Route array for one match: the item's detail view in its portal */
buildLink(item: CrossPortalSimilarItem): string[] {
return [
'/workspace/xtreams',
item.match.playlistId,
item.match.type === 'movie' ? 'vod' : 'series',
String(item.match.categoryId),
String(item.match.xtreamId),
];
}
}
@@ -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',
@@ -23,6 +23,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,
@@ -30,6 +31,7 @@ import {
TmdbMovieDetails,
TmdbPersonDetails,
TmdbSeasonDetails,
TmdbTrendingEntry,
TmdbTvDetails,
} from './tmdb.types';
@@ -50,6 +52,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();
@@ -84,6 +87,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<
@@ -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[];
}
/**
@@ -190,6 +190,43 @@
}
<div detail-extras>
@if (similarInPortals().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (item of similarInPortals(); track item.match.xtreamId) {
<button
type="button"
class="details__similar-card"
[title]="item.title + ' — ' + item.match.playlistName"
(click)="openSimilarInPortals(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
<span class="details__similar-source">
{{ item.match.playlistName }}
</span>
</button>
}
</div>
</section>
}
<!-- YouTube Trailer (provider or TMDB enrichment) -->
@if (trailerEmbedUrl(); as trailerUrl) {
<div class="youtube-trailer">
@@ -1,10 +1,4 @@
import {
Component,
computed,
inject,
input,
output,
} from '@angular/core';
import { Component, computed, effect, inject, input, output, signal, untracked } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { SafePipe } from '@iptvnator/pipes';
@@ -25,7 +19,11 @@ import {
normalizeVodDetails,
youtubeEmbedUrl,
} from '@iptvnator/shared/interfaces';
import { DownloadsService } from '@iptvnator/services';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
DownloadsService,
} from '@iptvnator/services';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
@@ -117,6 +115,7 @@ export class VodDetailsComponent {
// ============ Services ============
private readonly downloadsService = inject(DownloadsService);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
private readonly externalPlaybackActions = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly router = inject(Router);
@@ -134,6 +133,41 @@ export class VodDetailsComponent {
youtubeEmbedUrl(this.normalizedMeta().youtubeTrailer)
);
/**
* TMDB recommendations found in the user's OTHER portals (batched DB
* match, Electron only). Loaded async — the section appears when
* resolved; staleness-guarded against item changes in flight.
*/
readonly similarInPortals = signal<CrossPortalSimilarItem[]>([]);
private readonly loadSimilarInPortals = effect(() => {
const meta = this.normalizedMeta();
const recommendations = meta.tmdbRecommendations;
untracked(() => {
this.similarInPortals.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'movie')
.then((items) => {
if (
this.normalizedMeta().tmdbRecommendations ===
recommendations
) {
this.similarInPortals.set(items);
}
});
});
});
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
}
/** Whether there's a playback position to resume from */
readonly hasPlaybackPosition = computed(() => {
const pos = this.playbackPosition();
+9
View File
@@ -269,6 +269,15 @@
text-overflow: ellipsis;
white-space: nowrap;
}
// Source playlist badge on cross-portal similar cards
&__similar-source {
color: var(--text-muted);
font-size: 0.6875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// ============================================================================
@@ -1 +1,2 @@
export * from './lib/dashboard-data.service';
export * from './lib/dashboard-trending.service';
@@ -0,0 +1,130 @@
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 after a successful load', async () => {
const service = createService();
await service.load();
await service.load();
expect(getTrendingWeek).toHaveBeenCalledTimes(1);
});
it('retries on the next visit when the first load came back empty', async () => {
getTrendingWeek.mockResolvedValueOnce([]);
const service = createService();
await service.load();
expect(service.items()).toEqual([]);
await service.load();
expect(getTrendingWeek).toHaveBeenCalledTimes(2);
expect(service.items()).toHaveLength(1);
});
});
@@ -0,0 +1,88 @@
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;
}
/**
* Runs once per app session on success. Empty or failed loads (TMDB
* temporarily unreachable) do NOT latch, so the next dashboard visit
* retries instead of hiding the rail until an app restart.
*/
async load(): Promise<void> {
if (this.loadedOnce || this.loading() || !this.isAvailable) {
return;
}
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),
}))
);
this.loadedOnce = true;
} 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,83 @@
import { TestBed } from '@angular/core/testing';
import { TmdbEnrichmentService } from '@iptvnator/services';
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
describe('DashboardHeroTmdbService', () => {
const tvDetails = {
id: 100,
backdrop_path: '/serial.jpg',
vote_average: 7.4,
vote_count: 12,
genres: [{ id: 1, name: 'Drama' }, { id: 2, name: 'Comedy' }],
};
let isEnabled: jest.Mock;
let enrichMovie: jest.Mock;
let enrichTv: jest.Mock;
function createService(): DashboardHeroTmdbService {
TestBed.configureTestingModule({
providers: [
{
provide: TmdbEnrichmentService,
useValue: { isEnabled, enrichMovie, enrichTv },
},
],
});
return TestBed.inject(DashboardHeroTmdbService);
}
beforeEach(() => {
isEnabled = jest.fn().mockReturnValue(true);
enrichMovie = jest.fn().mockResolvedValue(null);
enrichTv = jest.fn().mockResolvedValue(tvDetails);
});
it('returns null when TMDB is disabled or the item is live', async () => {
isEnabled.mockReturnValue(false);
const service = createService();
await expect(
service.getExtras({ title: 'X', type: 'movie' })
).resolves.toBeNull();
isEnabled.mockReturnValue(true);
await expect(
service.getExtras({ title: 'X', type: 'live' })
).resolves.toBeNull();
expect(enrichMovie).not.toHaveBeenCalled();
});
it('falls back to a TV lookup for movie-typed items without a movie match', async () => {
// Stalker embedded-series items are typed 'movie' in activity rows
const service = createService();
const extras = await service.getExtras({
title: 'Между нами химия (8 серий)',
type: 'movie',
});
expect(enrichMovie).toHaveBeenCalledTimes(1);
expect(enrichTv).toHaveBeenCalledTimes(1);
expect(extras?.backdropUrl).toContain('/serial.jpg');
expect(extras?.rating).toBe('7.4');
expect(extras?.genres).toEqual(['Drama', 'Comedy']);
});
it('does not retry as TV when the movie lookup succeeds', async () => {
enrichMovie.mockResolvedValue({ ...tvDetails, id: 200 });
const service = createService();
await service.getExtras({ title: 'Heat', type: 'movie' });
expect(enrichTv).not.toHaveBeenCalled();
});
it('memoizes results per title and type', async () => {
const service = createService();
await service.getExtras({ title: 'The Boys', type: 'series' });
await service.getExtras({ title: 'The Boys', type: 'series' });
expect(enrichTv).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,101 @@
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>
>();
/** Reactive when read inside a computed (settings signal underneath) */
isEnabled(): boolean {
return this.enrichment.isEnabled();
}
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),
};
let details =
item.type === 'movie'
? await this.enrichment.enrichMovie(query)
: await this.enrichment.enrichTv(query);
if (!details && item.type === 'movie') {
// Stalker embedded-series items (vclub) are typed 'movie'
// in activity rows but are TV shows on TMDB — the detail
// view resolves them via is_series, so the TV lookup is
// usually already cached. Misses are negative-cached, so
// the retry never turns into repeated network traffic.
details = 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,17 @@ export class WorkspaceDashboardRailsComponent {
return null;
}
// Single reactive read, gated on the TMDB opt-in so cached
// extras vanish immediately when the user opts out mid-session
const extrasState = this.heroTmdbExtras();
const extras =
this.heroTmdb.isEnabled() &&
extrasState?.key === this.heroTmdbKey(item)
? extrasState.extras
: null;
const artwork = resolveDashboardHeroArtwork(
{
backdropUrl: item.backdrop_url,
backdropUrl: item.backdrop_url || (extras?.backdropUrl ?? null),
posterUrl: item.poster_url,
title: item.title,
},
@@ -158,6 +178,18 @@ export class WorkspaceDashboardRailsComponent {
item.type === 'live'
? this.getLiveEpgDetailsForCard(this.heroLiveCard())
: 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 +199,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 +326,18 @@ export class WorkspaceDashboardRailsComponent {
.map((item) => this.toRecentlyAddedCard(item))
);
readonly trendingCards = computed<DashboardRailCard[]>(() => {
this.languageTick();
// isAvailable reads the TMDB settings signal — flipping the
// opt-in off mid-session hides the rail immediately
if (!this.trendingService.isAvailable) {
return [];
}
return this.trendingService
.items()
.map((item) => this.toTrendingCard(item));
});
readonly sourceCards = computed<DashboardRailCard[]>(() =>
this.data.recentPlaylists().map((playlist) => ({
id: playlist._id,
@@ -338,6 +387,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 +564,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';