diff --git a/CLAUDE.md b/CLAUDE.md index fdfb23910..c665607a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -647,6 +647,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works - "Similar" rail in Xtream detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation - Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`) +- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint - Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) - Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts` - Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) 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 f87fad7f9..e075c136d 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: @@ -245,3 +246,27 @@ never blocks or delays rendering of the detail view. Similar/recommendations rails, actor cross-catalog search, trending dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache (IndexedDB). + +## Dashboard Integration + +- **Trending rail** ("Trending this week", `dashboardRails.tmdbTrending` + toggle): `DashboardTrendingService` + (`libs/workspace/dashboard/data-access`) pulls the weekly TMDB trending + lists (cached one day per language) and runs ONE batched + `CatalogTitleMatchService.matchTitles` request against the imported + Xtream playlists — matched cards navigate straight to their detail view + and show the playlist name; unmatched cards open the global search + prefilled. Requires both the TMDB opt-in and the Electron DB worker + (the rail is hidden in the PWA). The load fires only after the + dashboard's own recent/favorites data is in, so it never competes for + the worker at startup, and runs once per app session. +- **Hero extras**: `DashboardHeroTmdbService` + (`libs/workspace/dashboard/feature`) patches the hero card with a TMDB + backdrop (when the provider item has none), a rating badge and up to two + genre chips — resolved through the enrichment facade, so items already + opened in a detail view come from the SQLite cache without network. + Results are memoized per title for the session. The hero renders + immediately from provider data; extras appear when resolved. Series + heroes additionally show the tracked "S{n}·E{n}" badge from the playback + position (no TMDB involved); the watch-progress bar is limited to + movie/series heroes. 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 5558424b7..5c2ec0c92 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -19,6 +19,7 @@ import { import { TmdbPersonService } from './tmdb-person.service'; import { TmdbRuntimeService } from './tmdb-runtime.service'; import { TmdbSeasonService } from './tmdb-season.service'; +import { TmdbTrendingService } from './tmdb-trending.service'; import { TmdbDetails, TmdbEnrichmentQuery, @@ -26,6 +27,7 @@ import { TmdbMovieDetails, TmdbPersonDetails, TmdbSeasonDetails, + TmdbTrendingEntry, TmdbTvDetails, } from './tmdb.types'; @@ -46,6 +48,7 @@ export class TmdbEnrichmentService { private readonly cache = inject(TmdbCacheService); private readonly person = inject(TmdbPersonService); private readonly season = inject(TmdbSeasonService); + private readonly trending = inject(TmdbTrendingService); isEnabled(): boolean { return this.runtime.isEnabled(); @@ -80,6 +83,11 @@ export class TmdbEnrichmentService { return this.season.getSeason(tmdbId, seasonNumber); } + /** Weekly trending titles (movies + series merged by popularity) */ + async getTrendingWeek(limit?: number): Promise { + 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 ead6973ec..05473390a 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/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..c3dd52346 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.spec.ts @@ -0,0 +1,118 @@ +import { TestBed } from '@angular/core/testing'; +import { + CatalogTitleMatchService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import type { TmdbTrendingEntry } from '@iptvnator/services'; +import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; +import { DashboardTrendingService } from './dashboard-trending.service'; + +describe('DashboardTrendingService', () => { + const entry = ( + overrides: Partial = {} + ): TmdbTrendingEntry => ({ + 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', async () => { + const service = createService(); + + await service.load(); + await service.load(); + + expect(getTrendingWeek).toHaveBeenCalledTimes(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..856b7c1dd --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-trending.service.ts @@ -0,0 +1,84 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { + CatalogTitleMatchService, + TmdbEnrichmentService, + buildTitleMatchIndex, +} from '@iptvnator/services'; +import { + CatalogTitleMatch, + normalizeTitleKeys, + titleYearsCompatible, +} from '@iptvnator/shared/interfaces'; +import type { TmdbTrendingEntry } from '@iptvnator/services'; + +/** One trending card: TMDB entry + optional library match for navigation */ +export interface DashboardTrendingItem extends TmdbTrendingEntry { + /** Confident match in an imported Xtream playlist, when found */ + match: CatalogTitleMatch | null; +} + +/** + * "Trending this week" dashboard rail data. Requires the TMDB opt-in and + * (for availability matching / navigation) the Electron DB worker — the + * rail is hidden in the PWA. Trending lists are cached for a day, and the + * title match runs as ONE batched worker request, fired only after the + * dashboard's own data has loaded so it never competes for the worker. + */ +@Injectable({ providedIn: 'root' }) +export class DashboardTrendingService { + private readonly enrichment = inject(TmdbEnrichmentService); + private readonly titleMatch = inject(CatalogTitleMatchService); + + readonly items = signal([]); + readonly loading = signal(false); + + private loadedOnce = false; + + get isAvailable(): boolean { + return this.enrichment.isEnabled() && this.titleMatch.isAvailable; + } + + /** Idempotent per app session — dashboard revisits reuse the signal */ + async load(): Promise { + if (this.loadedOnce || !this.isAvailable) { + return; + } + this.loadedOnce = true; + this.loading.set(true); + + try { + const entries = await this.enrichment.getTrendingWeek(); + if (entries.length === 0) { + return; + } + + const matches = await this.titleMatch.matchTitles( + entries.map((entry) => entry.title) + ); + const index = buildTitleMatchIndex(matches); + + this.items.set( + entries.map((entry) => ({ + ...entry, + match: this.matchFor(entry, index), + })) + ); + } catch (error) { + console.warn('Dashboard trending load failed:', error); + } finally { + this.loading.set(false); + } + } + + private matchFor( + entry: TmdbTrendingEntry, + index: ReadonlyMap + ): 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.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts new file mode 100644 index 000000000..cbdf91b18 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts @@ -0,0 +1,88 @@ +import { Injectable, inject } from '@angular/core'; +import { + TmdbEnrichmentService, + extractYear, + tmdbBackdropUrl, +} from '@iptvnator/services'; +import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access'; + +/** TMDB extras for the dashboard hero, patched in after first paint */ +export interface DashboardHeroTmdbExtras { + readonly backdropUrl: string | null; + readonly rating: string | null; + readonly genres: readonly string[]; +} + +const MAX_HERO_GENRES = 2; + +/** + * Best-effort TMDB extras for the hero card (backdrop, rating, genres). + * Goes through the enrichment facade, so items already opened in a detail + * view resolve from the SQLite cache without network. Results are memoized + * per title for the session — dashboard revisits skip the IPC round-trip. + */ +@Injectable({ providedIn: 'root' }) +export class DashboardHeroTmdbService { + private readonly enrichment = inject(TmdbEnrichmentService); + private readonly memo = new Map< + string, + Promise + >(); + + 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), + }; + const details = + item.type === 'movie' + ? await this.enrichment.enrichMovie(query) + : await this.enrichment.enrichTv(query); + if (!details) { + return null; + } + + const rating = + (details.vote_count ?? 0) > 0 && details.vote_average + ? details.vote_average.toFixed(1) + : null; + const genres = (details.genres ?? []) + .map((genre) => genre.name) + .filter(Boolean) + .slice(0, MAX_HERO_GENRES); + + return { + backdropUrl: tmdbBackdropUrl(details.backdrop_path), + rating, + genres, + }; + } catch (error) { + console.warn('Dashboard hero TMDB extras failed:', error); + return null; + } + } +} 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..44ebd68d7 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,14 @@ export class WorkspaceDashboardRailsComponent { return null; } + const extrasForArtwork = this.heroTmdbExtras(); + const tmdbBackdrop = + extrasForArtwork?.key === this.heroTmdbKey(item) + ? (extrasForArtwork.extras?.backdropUrl ?? null) + : null; const artwork = resolveDashboardHeroArtwork( { - backdropUrl: item.backdrop_url, + backdropUrl: item.backdrop_url || tmdbBackdrop, posterUrl: item.poster_url, title: item.title, }, @@ -158,6 +175,23 @@ export class WorkspaceDashboardRailsComponent { item.type === 'live' ? this.getLiveEpgDetailsForCard(this.heroLiveCard()) : null; + const extrasState = this.heroTmdbExtras(); + const extras = + extrasState?.key === this.heroTmdbKey(item) + ? extrasState.extras + : null; + const episodeBadge = + item.type === 'series' && + position?.seasonNumber != null && + position?.episodeNumber != null + ? this.translate.instant( + 'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE', + { + season: position.seasonNumber, + episode: position.episodeNumber, + } + ) + : null; return { ...artwork, @@ -167,8 +201,13 @@ export class WorkspaceDashboardRailsComponent { state: this.data.getRecentItemNavigationState(item), subtitle: this.buildHeroSubtitle(item), title: item.title, - watchProgress: playbackProgressPercent(position), - remainingLabel: formatRemainingLabel(position), + rating: extras?.rating ?? null, + genres: extras?.genres ?? [], + episodeBadge, + watchProgress: + item.type === 'live' ? null : playbackProgressPercent(position), + remainingLabel: + item.type === 'live' ? null : formatRemainingLabel(position), nowPlayingTitle: liveEpgDetails?.nowPlayingTitle ?? null, nowPlayingTimeRange: liveEpgDetails?.nowPlayingTimeRange ?? null, nowPlayingProgress: liveEpgDetails?.nowPlayingProgress ?? null, @@ -289,6 +328,13 @@ export class WorkspaceDashboardRailsComponent { .map((item) => this.toRecentlyAddedCard(item)) ); + readonly trendingCards = computed(() => { + this.languageTick(); + return this.trendingService + .items() + .map((item) => this.toTrendingCard(item)); + }); + readonly sourceCards = computed(() => this.data.recentPlaylists().map((playlist) => ({ id: playlist._id, @@ -338,6 +384,40 @@ export class WorkspaceDashboardRailsComponent { this.playbackPositionReloadKey(); untracked(() => void this.data.reloadPlaybackPositions()); }); + + // TMDB extras for the hero (backdrop/rating/genres) — async after + // first paint, staleness-guarded against hero changes in flight. + effect(() => { + const item = this.heroRecentItem(); + if (!item || (item.type !== 'movie' && item.type !== 'series')) { + return; + } + const key = this.heroTmdbKey(item); + untracked(() => { + if (this.heroTmdbExtras()?.key === key) { + return; + } + void this.heroTmdb.getExtras(item).then((extras) => { + const current = untracked(() => this.heroRecentItem()); + if (current && this.heroTmdbKey(current) === key) { + this.heroTmdbExtras.set({ key, extras }); + } + }); + }); + }); + + // Trending rail: needs the TMDB opt-in and the Electron DB worker. + // Deferred until the dashboard's own recent/favorites data is in so + // the batched title match never competes for the worker at startup. + effect(() => { + if ( + !this.dashboardRails().tmdbTrending || + !this.data.globalFavoritesLoaded() + ) { + return; + } + untracked(() => void this.trendingService.load()); + }); } onAddPlaylist(type?: WorkspacePlaylistType): void { @@ -481,6 +561,38 @@ export class WorkspaceDashboardRailsComponent { }; } + private heroTmdbKey(item: GlobalRecentItem): string { + return `${item.type}:${item.title}`; + } + + private toTrendingCard(item: DashboardTrendingItem): DashboardRailCard { + const subtitle = [ + item.year !== null ? String(item.year) : null, + item.rating ? `★ ${item.rating}` : null, + item.match?.playlistName ?? null, + ] + .filter((value): value is string => Boolean(value)) + .join(' · '); + return { + id: `trending-${item.mediaType}-${item.tmdbId}`, + title: item.title, + subtitle, + imageUrl: item.posterUrl ?? undefined, + icon: item.mediaType === 'movie' ? 'movie' : 'video_library', + contentType: item.mediaType === 'movie' ? 'movie' : 'series', + link: item.match + ? [ + '/workspace/xtreams', + item.match.playlistId, + item.match.type === 'movie' ? 'vod' : 'series', + String(item.match.categoryId), + String(item.match.xtreamId), + ] + : ['/workspace/search'], + ...(item.match ? {} : { queryParams: { q: item.title } }), + }; + } + private typeIcon(type: 'live' | 'movie' | 'series'): string { if (type === 'live') return 'live_tv'; if (type === 'movie') return 'movie';