From 862b271eac1b26ee413ccf5a15ad5f770c84398c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:21:11 +0200 Subject: [PATCH] feat(tmdb): dashboard trending + hero enrichment, cross-portal Similar rail (#1132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- CLAUDE.md | 3 +- .../settings-dashboard-section.component.html | 18 +++ .../src/app/settings/settings-form.utils.ts | 1 + .../app/settings/settings.component.spec.ts | 1 + apps/web/src/assets/i18n/ar.json | 3 + apps/web/src/assets/i18n/ary.json | 3 + apps/web/src/assets/i18n/by.json | 3 + apps/web/src/assets/i18n/de.json | 3 + apps/web/src/assets/i18n/el.json | 3 + apps/web/src/assets/i18n/en.json | 3 + apps/web/src/assets/i18n/es.json | 3 + apps/web/src/assets/i18n/fr.json | 3 + apps/web/src/assets/i18n/it.json | 3 + apps/web/src/assets/i18n/ja.json | 3 + apps/web/src/assets/i18n/ko.json | 3 + apps/web/src/assets/i18n/nl.json | 3 + apps/web/src/assets/i18n/pl.json | 3 + apps/web/src/assets/i18n/pt.json | 3 + apps/web/src/assets/i18n/ru.json | 3 + apps/web/src/assets/i18n/tr.json | 3 + apps/web/src/assets/i18n/zh.json | 3 + apps/web/src/assets/i18n/zhtw.json | 3 + docs/architecture/tmdb-metadata-enrichment.md | 39 +++++- .../stalker-series-view.component.html | 47 +++++++ .../stalker-series-view.component.ts | 38 +++++ .../serial-details.component.html | 38 ++++- .../serial-details.component.ts | 50 +++++++ .../vod-details-route.component.html | 38 ++++- .../vod-details-route.component.ts | 57 +++++++- libs/services/src/index.ts | 1 + .../lib/cross-portal-similar.service.spec.ts | 115 ++++++++++++++++ .../src/lib/cross-portal-similar.service.ts | 101 ++++++++++++++ .../src/lib/settings-store.service.spec.ts | 1 + .../services/src/lib/tmdb/tmdb-api.service.ts | 14 ++ libs/services/src/lib/tmdb/tmdb-config.ts | 3 + .../src/lib/tmdb/tmdb-enrichment.service.ts | 8 ++ .../src/lib/tmdb/tmdb-trending.service.ts | 110 +++++++++++++++ libs/services/src/lib/tmdb/tmdb.types.ts | 13 ++ .../interfaces/src/lib/settings.interface.ts | 3 + .../src/lib/vod-details-adapters.ts | 2 + .../src/lib/vod-details-item.interface.ts | 6 +- .../vod-details/vod-details.component.html | 37 +++++ .../lib/vod-details/vod-details.component.ts | 50 +++++-- libs/ui/styles/_detail-view.scss | 9 ++ .../dashboard/data-access/src/index.ts | 1 + .../lib/dashboard-trending.service.spec.ts | 130 ++++++++++++++++++ .../src/lib/dashboard-trending.service.ts | 88 ++++++++++++ .../rails/dashboard-hero-tmdb.service.spec.ts | 83 +++++++++++ .../lib/rails/dashboard-hero-tmdb.service.ts | 101 ++++++++++++++ .../src/lib/rails/dashboard-hero.utils.ts | 6 + .../lib/rails/dashboard-rail.component.html | 2 + .../src/lib/rails/dashboard-rail.component.ts | 1 + .../workspace-dashboard-rails.component.html | 54 ++++++++ .../workspace-dashboard-rails.component.scss | 44 ++++++ .../workspace-dashboard-rails.component.ts | 121 +++++++++++++++- 55 files changed, 1467 insertions(+), 21 deletions(-) create mode 100644 libs/services/src/lib/cross-portal-similar.service.spec.ts create mode 100644 libs/services/src/lib/cross-portal-similar.service.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-trending.service.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts diff --git a/CLAUDE.md b/CLAUDE.md index fdfb23910..a18670bde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.html b/apps/web/src/app/settings/settings-dashboard-section.component.html index 57b690a0a..8e2db7c31 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.html +++ b/apps/web/src/app/settings/settings-dashboard-section.component.html @@ -160,5 +160,23 @@ > + +
+
+

{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}

+

+ {{ + 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION' + | translate + }} +

+
+
+ +
+
diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 3ce21e9d8..19a52df6c 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -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, diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index c4c762221..245985f79 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -69,6 +69,7 @@ const DEFAULT_DASHBOARD_RAILS = { favoriteMoviesAndSeries: true, recentSources: true, xtreamRecentlyAdded: true, + tmdbTrending: true, }; class MatSnackBarStub { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index ba2530487..1b60023c5 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 0c7280ba1..3eebb6c41 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 454e6c65f..c8ad4d727 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 015d8e617..de241c02e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 44eb43e3b..eb492d967 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 0c10f8cef..f60e9abf5 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 9fea237d8..a9adb016d 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 153aed137..ae178064b 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index ae4fcf81d..482c397e1 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 1b4143563..dc380258e 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index bb8a15cc4..3298b6076 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 6feeaa613..0c64bcba3 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index cb9af4de2..90e586907 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index ceddf223d..6b20a4905 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 548ce29b2..33b3bc6f0 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 36bbb0ed9..677c3e03b 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 777ae5d33..8f5540085 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 495789324..09dafd1af 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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", diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 1960d0cad..eabdb5d27 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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:` rows) | | `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:\|season:` 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. diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index b2f1c99d7..3b90c38b0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -166,6 +166,53 @@ }
+ @if (similarInPortals().length) { +
+

{{ 'XTREAM.SIMILAR' | translate }}

+
+ @for ( + item of similarInPortals(); + track item.match.xtreamId + ) { + + } +
+
+ } + @if (trailerEmbedUrl(); as trailerUrl) {
diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 7d26a7f7c..5e7cb5da8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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([]); + 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(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) { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index d7bfb3592..a46abaf26 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -165,7 +165,7 @@ />
- @if (similarItems().length) { + @if (similarItems().length || similarInPortals().length) {

{{ 'XTREAM.SIMILAR' | translate }}

@@ -195,6 +195,42 @@ } + @for ( + item of similarInPortals(); + track 'x' + item.match.playlistId + '-' + item.match.xtreamId + ) { + + }
} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 1c55380c4..d84a83be2 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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([]); + readonly similarInPortals = computed(() => { + 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, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index c95b2a366..1a5494699 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -237,7 +237,7 @@
} - @if (similarItems().length) { + @if (similarItems().length || similarInPortals().length) {

{{ 'XTREAM.SIMILAR' | translate }}

@@ -267,6 +267,42 @@ } + @for ( + item of similarInPortals(); + track 'x' + item.match.playlistId + '-' + item.match.xtreamId + ) { + + }
} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 463023e0c..ac33511eb 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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([]); + readonly similarInPortals = computed(() => { + 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, diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index d5756089e..48a487587 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/cross-portal-similar.service.spec.ts b/libs/services/src/lib/cross-portal-similar.service.spec.ts new file mode 100644 index 000000000..81a48bf76 --- /dev/null +++ b/libs/services/src/lib/cross-portal-similar.service.spec.ts @@ -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 => ({ + 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([]); + }); +}); diff --git a/libs/services/src/lib/cross-portal-similar.service.ts b/libs/services/src/lib/cross-portal-similar.service.ts new file mode 100644 index 000000000..73081bc83 --- /dev/null +++ b/libs/services/src/lib/cross-portal-similar.service.ts @@ -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 { + 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(); + 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), + ]; + } +} diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index b7793d487..9f9ae531a 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -23,6 +23,7 @@ const expectedDashboardRails = ( favoriteMoviesAndSeries: true, recentSources: true, xtreamRecentlyAdded: true, + tmdbTrending: true, ...overrides, }); diff --git a/libs/services/src/lib/tmdb/tmdb-api.service.ts b/libs/services/src/lib/tmdb/tmdb-api.service.ts index 7cde109f2..4d7144591 100644 --- a/libs/services/src/lib/tmdb/tmdb-api.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-api.service.ts @@ -76,6 +76,20 @@ export class TmdbApiService { ); } + /** Weekly trending titles for one media type */ + async getTrending( + mediaType: 'movie' | 'tv', + language: string, + apiKey: string + ): Promise { + const response = await this.request( + `/trending/${mediaType}/week`, + { language }, + apiKey + ); + return response.results ?? []; + } + async getPersonDetails( personId: number, language: string, diff --git a/libs/services/src/lib/tmdb/tmdb-config.ts b/libs/services/src/lib/tmdb/tmdb-config.ts index 61ce4a4a4..020a5a6ab 100644 --- a/libs/services/src/lib/tmdb/tmdb-config.ts +++ b/libs/services/src/lib/tmdb/tmdb-config.ts @@ -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.ARABIC]: 'ar-SA', diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts index 5bad35c98..0ce8339c6 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -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 { + return this.trending.getTrendingWeek(limit); + } + async getPersonDetails( personId: number ): Promise { diff --git a/libs/services/src/lib/tmdb/tmdb-trending.service.ts b/libs/services/src/lib/tmdb/tmdb-trending.service.ts new file mode 100644 index 000000000..ced5b1b69 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-trending.service.ts @@ -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 { + 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 { + 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 !== ''); + } +} diff --git a/libs/services/src/lib/tmdb/tmdb.types.ts b/libs/services/src/lib/tmdb/tmdb.types.ts index 7fefae1f5..87565fa44 100644 --- a/libs/services/src/lib/tmdb/tmdb.types.ts +++ b/libs/services/src/lib/tmdb/tmdb.types.ts @@ -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[]; } diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index a4cb2b5a5..0242ca73a 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -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< diff --git a/libs/shared/interfaces/src/lib/vod-details-adapters.ts b/libs/shared/interfaces/src/lib/vod-details-adapters.ts index c94d86a78..ff1520a72 100644 --- a/libs/shared/interfaces/src/lib/vod-details-adapters.ts +++ b/libs/shared/interfaces/src/lib/vod-details-adapters.ts @@ -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, }; } diff --git a/libs/shared/interfaces/src/lib/vod-details-item.interface.ts b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts index 07f3f14b7..d66bfab8f 100644 --- a/libs/shared/interfaces/src/lib/vod-details-item.interface.ts +++ b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts @@ -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[]; } /** diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index 913645971..59730c10b 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -190,6 +190,43 @@ }
+ @if (similarInPortals().length) { +
+

{{ 'XTREAM.SIMILAR' | translate }}

+
+ @for (item of similarInPortals(); track item.match.xtreamId) { + + } +
+
+ } + @if (trailerEmbedUrl(); as trailerUrl) {
diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index d8e2985e2..27d151643 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -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([]); + + 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(); diff --git a/libs/ui/styles/_detail-view.scss b/libs/ui/styles/_detail-view.scss index 4abf2f5a5..49f8c0f50 100644 --- a/libs/ui/styles/_detail-view.scss +++ b/libs/ui/styles/_detail-view.scss @@ -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; + } } // ============================================================================ diff --git a/libs/workspace/dashboard/data-access/src/index.ts b/libs/workspace/dashboard/data-access/src/index.ts index 968d33e39..fe59f79f0 100644 --- a/libs/workspace/dashboard/data-access/src/index.ts +++ b/libs/workspace/dashboard/data-access/src/index.ts @@ -1 +1,2 @@ export * from './lib/dashboard-data.service'; +export * from './lib/dashboard-trending.service'; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts new file mode 100644 index 000000000..9f8c1595b --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts @@ -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 => ({ + tmdbId: 603, + mediaType: 'movie', + title: 'The Matrix', + year: 1999, + posterUrl: null, + rating: '8.2', + popularity: 100, + ...overrides, + }); + + const match = ( + overrides: Partial = {} + ): 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); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts new file mode 100644 index 000000000..a67e387d1 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts @@ -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([]); + 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 { + 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 + ): 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; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts new file mode 100644 index 000000000..ccbd48a1b --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts @@ -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); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts new file mode 100644 index 000000000..2a449d5d9 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts @@ -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 + >(); + + /** Reactive when read inside a computed (settings signal underneath) */ + isEnabled(): boolean { + return this.enrichment.isEnabled(); + } + + getExtras( + item: Pick + ): Promise { + 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 + ): Promise { + 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; + } + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts index 25cb8dd23..6d80b7aca 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts @@ -14,6 +14,12 @@ export interface DashboardHeroModel { readonly state?: Record; 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; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index a7fbceff0..a61bc5506 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -66,6 +66,7 @@
@@ -141,6 +142,7 @@
; state?: Record; actions?: DashboardRailAction[]; epgLookupKey?: string; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 1ab02c2d8..e24182c67 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -77,6 +77,35 @@

{{ item.title }}

{{ item.subtitle }}

+ @if ( + item.episodeBadge || + item.rating || + (item.genres && item.genres.length > 0) + ) { + + @if (item.episodeBadge) { + {{ item.episodeBadge }} + } + @if (item.rating) { + ★ {{ item.rating }} + } + @for (genre of item.genres; track genre) { + {{ + genre + }} + } + + } + @if ( item.contentType === 'live' && (item.nowPlayingTitle || @@ -325,5 +354,30 @@
} + + + @if (dashboardRails().tmdbTrending && trendingCards().length > 0) { + + } @else if ( + dashboardRails().tmdbTrending && trendingService.loading() + ) { + + } } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss index e5af99b7e..ccf9ddf99 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss @@ -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; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 049309769..62fb50393 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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(() => { 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(() => { + 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(() => 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';