mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat(dashboard): TMDB trending rail and hero enrichment (backdrop, badges, S/E)
Two dashboard additions, both async after first paint so the page renders
exactly as fast as before:
Trending rail ("Trending this week", dashboardRails.tmdbTrending toggle,
default on, rendered only when TMDB is opted in AND the Electron DB
worker is available):
- TmdbTrendingService fetches /trending/{movie,tv}/week (one request
each, cached one day per language in tmdb_metadata under
trending:week), merges by popularity; exposed via the enrichment
facade (getTrendingWeek)
- DashboardTrendingService matches the titles against imported Xtream
playlists with ONE batched DB_MATCH_TITLES request, applying the same
two-tier + year-compatibility rule as actor pages; matched cards show
the playlist name and navigate straight to the detail view, unmatched
cards open the global search prefilled (?q=)
- The load fires only after the dashboard's own recent/favorites data
is in (never competes for the worker at startup) and once per session
- DashboardRailCard gained optional queryParams for the search links
Hero enrichment:
- DashboardHeroTmdbService patches the hero with a TMDB backdrop (only
when the item has none), a rating badge and up to two genre chips —
via the enrichment facade, so previously opened items resolve from
the SQLite cache without network; memoized per title per session,
staleness-guarded against hero changes in flight
- Series heroes show the tracked "S{n}·E{n}" badge from the playback
position; the watch-progress bar no longer applies to live heroes
Settings: new dashboardRails.tmdbTrending toggle in Settings > Dashboard.
i18n: 3 new keys translated into all 17 locales via tools/i18n patches.
Tests: dashboard-trending.service.spec.ts (gating, matching, year guard,
single-flight); settings fixtures updated. Docs updated
(tmdb-metadata-enrichment.md Dashboard Integration section, CLAUDE.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce24e16e32
commit
d77a6a28cb
40 files changed
+765
-3
No files matched your search
@@ -647,6 +647,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works
|
||||
- "Similar" rail in Xtream detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation
|
||||
- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`)
|
||||
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint
|
||||
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
|
||||
- Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts`
|
||||
- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)
|
||||
|
||||
@@ -160,5 +160,23 @@
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="tmdbTrending"
|
||||
data-test-id="toggle-dashboard-rail-tmdb-trending"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -50,6 +50,7 @@ export function createSettingsForm(
|
||||
recentSources: DEFAULT_DASHBOARD_RAILS_SETTINGS.recentSources,
|
||||
xtreamRecentlyAdded:
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS.xtreamRecentlyAdded,
|
||||
tmdbTrending: DEFAULT_DASHBOARD_RAILS_SETTINGS.tmdbTrending,
|
||||
}),
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
showExternalPlaybackBar: true,
|
||||
|
||||
@@ -69,6 +69,7 @@ const DEFAULT_DASHBOARD_RAILS = {
|
||||
favoriteMoviesAndSeries: true,
|
||||
recentSources: true,
|
||||
xtreamRecentlyAdded: true,
|
||||
tmdbTrending: true,
|
||||
};
|
||||
|
||||
class MatSnackBarStub {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -410,6 +410,8 @@
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_TMDB_TRENDING": "本週熱門(TMDB)",
|
||||
"DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "顯示 TMDB 每週熱門電影和影集,並與您的媒體庫比對。需要啟用 TMDB 中繼資料。",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
@@ -1025,6 +1027,7 @@
|
||||
"SEE_ALL_COUNT": "查看全部 {{count}} 個",
|
||||
"RECENTLY_WATCHED": "最近觀看",
|
||||
"XTREAM_RECENTLY_ADDED": "Xtream 最近新增",
|
||||
"TMDB_TRENDING": "本週熱門",
|
||||
"NO_HISTORY": "找不到符合此篩選條件的項目。",
|
||||
"NO_HISTORY_HINT": "從您的播放清單開始播放內容後,您的觀看記錄就會出現在此處。",
|
||||
"XTREAM": "Xtream",
|
||||
|
||||
@@ -46,6 +46,7 @@ store imports):
|
||||
| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups |
|
||||
| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:<id>` rows) |
|
||||
| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:<id>\|season:<n>` rows) |
|
||||
| `tmdb-trending.service.ts` | Weekly trending (movie + tv merged by popularity, `trending:week` rows, 1-day TTL) |
|
||||
|
||||
Integration glue per portal:
|
||||
|
||||
@@ -245,3 +246,27 @@ never blocks or delays rendering of the detail view.
|
||||
Similar/recommendations rails, actor cross-catalog search, trending
|
||||
dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache
|
||||
(IndexedDB).
|
||||
|
||||
## Dashboard Integration
|
||||
|
||||
- **Trending rail** ("Trending this week", `dashboardRails.tmdbTrending`
|
||||
toggle): `DashboardTrendingService`
|
||||
(`libs/workspace/dashboard/data-access`) pulls the weekly TMDB trending
|
||||
lists (cached one day per language) and runs ONE batched
|
||||
`CatalogTitleMatchService.matchTitles` request against the imported
|
||||
Xtream playlists — matched cards navigate straight to their detail view
|
||||
and show the playlist name; unmatched cards open the global search
|
||||
prefilled. Requires both the TMDB opt-in and the Electron DB worker
|
||||
(the rail is hidden in the PWA). The load fires only after the
|
||||
dashboard's own recent/favorites data is in, so it never competes for
|
||||
the worker at startup, and runs once per app session.
|
||||
- **Hero extras**: `DashboardHeroTmdbService`
|
||||
(`libs/workspace/dashboard/feature`) patches the hero card with a TMDB
|
||||
backdrop (when the provider item has none), a rating badge and up to two
|
||||
genre chips — resolved through the enrichment facade, so items already
|
||||
opened in a detail view come from the SQLite cache without network.
|
||||
Results are memoized per title for the session. The hero renders
|
||||
immediately from provider data; extras appear when resolved. Series
|
||||
heroes additionally show the tracked "S{n}·E{n}" badge from the playback
|
||||
position (no TMDB involved); the watch-progress bar is limited to
|
||||
movie/series heroes.
|
||||
@@ -23,6 +23,7 @@ const expectedDashboardRails = (
|
||||
favoriteMoviesAndSeries: true,
|
||||
recentSources: true,
|
||||
xtreamRecentlyAdded: true,
|
||||
tmdbTrending: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -76,6 +76,20 @@ export class TmdbApiService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Weekly trending titles for one media type */
|
||||
async getTrending(
|
||||
mediaType: 'movie' | 'tv',
|
||||
language: string,
|
||||
apiKey: string
|
||||
): Promise<TmdbSearchResult[]> {
|
||||
const response = await this.request<TmdbSearchResponse>(
|
||||
`/trending/${mediaType}/week`,
|
||||
{ language },
|
||||
apiKey
|
||||
);
|
||||
return response.results ?? [];
|
||||
}
|
||||
|
||||
async getPersonDetails(
|
||||
personId: number,
|
||||
language: string,
|
||||
|
||||
@@ -26,6 +26,9 @@ export const TMDB_MATCH_CACHE_TTL_MS = 30 * DAY_MS;
|
||||
/** Negative matches retry sooner — providers fix titles, TMDB adds entries */
|
||||
export const TMDB_NEGATIVE_MATCH_CACHE_TTL_MS = 7 * DAY_MS;
|
||||
|
||||
/** Weekly trending rotates slowly — refresh the dashboard rail daily */
|
||||
export const TMDB_TRENDING_CACHE_TTL_MS = 1 * DAY_MS;
|
||||
|
||||
/** App language → TMDB language-region code for localized metadata */
|
||||
const TMDB_LANGUAGE_MAP: Record<Language, string> = {
|
||||
[Language.ARABIC]: 'ar-SA',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { TmdbPersonService } from './tmdb-person.service';
|
||||
import { TmdbRuntimeService } from './tmdb-runtime.service';
|
||||
import { TmdbSeasonService } from './tmdb-season.service';
|
||||
import { TmdbTrendingService } from './tmdb-trending.service';
|
||||
import {
|
||||
TmdbDetails,
|
||||
TmdbEnrichmentQuery,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
TmdbMovieDetails,
|
||||
TmdbPersonDetails,
|
||||
TmdbSeasonDetails,
|
||||
TmdbTrendingEntry,
|
||||
TmdbTvDetails,
|
||||
} from './tmdb.types';
|
||||
|
||||
@@ -46,6 +48,7 @@ export class TmdbEnrichmentService {
|
||||
private readonly cache = inject(TmdbCacheService);
|
||||
private readonly person = inject(TmdbPersonService);
|
||||
private readonly season = inject(TmdbSeasonService);
|
||||
private readonly trending = inject(TmdbTrendingService);
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.runtime.isEnabled();
|
||||
@@ -80,6 +83,11 @@ export class TmdbEnrichmentService {
|
||||
return this.season.getSeason(tmdbId, seasonNumber);
|
||||
}
|
||||
|
||||
/** Weekly trending titles (movies + series merged by popularity) */
|
||||
async getTrendingWeek(limit?: number): Promise<TmdbTrendingEntry[]> {
|
||||
return this.trending.getTrendingWeek(limit);
|
||||
}
|
||||
|
||||
async getPersonDetails(
|
||||
personId: number
|
||||
): Promise<TmdbPersonDetails | null> {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { TmdbApiService } from './tmdb-api.service';
|
||||
import { TmdbCacheService } from './tmdb-cache.service';
|
||||
import {
|
||||
TMDB_TRENDING_CACHE_TTL_MS,
|
||||
tmdbPosterUrl,
|
||||
} from './tmdb-config';
|
||||
import { extractYear } from './tmdb-matcher';
|
||||
import { TmdbRuntimeService } from './tmdb-runtime.service';
|
||||
import { TmdbSearchResult, TmdbTrendingEntry } from './tmdb.types';
|
||||
|
||||
/**
|
||||
* Weekly TMDB trending for the dashboard rail. Movie and TV lists are
|
||||
* fetched separately (one request each, cached for a day per language)
|
||||
* and merged by popularity.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TmdbTrendingService {
|
||||
private readonly runtime = inject(TmdbRuntimeService);
|
||||
private readonly api = inject(TmdbApiService);
|
||||
private readonly cache = inject(TmdbCacheService);
|
||||
|
||||
async getTrendingWeek(limit = 18): Promise<TmdbTrendingEntry[]> {
|
||||
if (!this.runtime.isEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [movies, tv] = await Promise.all([
|
||||
this.getTrendingFor('movie'),
|
||||
this.getTrendingFor('tv'),
|
||||
]);
|
||||
|
||||
return [...movies, ...tv]
|
||||
.sort((a, b) => b.popularity - a.popularity)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
private async getTrendingFor(
|
||||
mediaType: 'movie' | 'tv'
|
||||
): Promise<TmdbTrendingEntry[]> {
|
||||
try {
|
||||
const language = this.runtime.language();
|
||||
const lookupKey = 'trending:week';
|
||||
|
||||
const cached = await this.cache.get(
|
||||
mediaType,
|
||||
lookupKey,
|
||||
language
|
||||
);
|
||||
if (
|
||||
this.cache.isFresh(cached, TMDB_TRENDING_CACHE_TTL_MS) &&
|
||||
cached?.payload
|
||||
) {
|
||||
try {
|
||||
return this.toEntries(
|
||||
JSON.parse(cached.payload) as TmdbSearchResult[],
|
||||
mediaType
|
||||
);
|
||||
} catch {
|
||||
// Corrupt cache row — fall through to a fresh fetch
|
||||
}
|
||||
}
|
||||
|
||||
const results = await this.api.getTrending(
|
||||
mediaType,
|
||||
language,
|
||||
this.runtime.apiKey()
|
||||
);
|
||||
|
||||
await this.cache.set({
|
||||
mediaType,
|
||||
lookupKey,
|
||||
language,
|
||||
tmdbId: null,
|
||||
payload: JSON.stringify(results),
|
||||
});
|
||||
|
||||
return this.toEntries(results, mediaType);
|
||||
} catch (error) {
|
||||
console.warn(`TMDB trending (${mediaType}) failed:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private toEntries(
|
||||
results: TmdbSearchResult[],
|
||||
mediaType: 'movie' | 'tv'
|
||||
): TmdbTrendingEntry[] {
|
||||
return results
|
||||
.map((result) => {
|
||||
const title = result.title ?? result.name ?? '';
|
||||
const rating =
|
||||
(result.vote_count ?? 0) > 0 && result.vote_average
|
||||
? result.vote_average.toFixed(1)
|
||||
: null;
|
||||
return {
|
||||
tmdbId: result.id,
|
||||
mediaType,
|
||||
title,
|
||||
year: extractYear(
|
||||
result.release_date ?? result.first_air_date
|
||||
),
|
||||
posterUrl: tmdbPosterUrl(result.poster_path),
|
||||
rating,
|
||||
popularity: result.popularity ?? 0,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.tmdbId > 0 && entry.title !== '');
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,22 @@ export interface TmdbSearchResult {
|
||||
first_air_date?: string;
|
||||
popularity?: number;
|
||||
vote_count?: number;
|
||||
vote_average?: number;
|
||||
poster_path?: string | null;
|
||||
}
|
||||
|
||||
/** One dashboard-ready trending title (movie or series) */
|
||||
export interface TmdbTrendingEntry {
|
||||
tmdbId: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
title: string;
|
||||
year: number | null;
|
||||
posterUrl: string | null;
|
||||
/** vote_average rounded to one decimal, null without votes */
|
||||
rating: string | null;
|
||||
popularity: number;
|
||||
}
|
||||
|
||||
export interface TmdbSearchResponse {
|
||||
results?: TmdbSearchResult[];
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface DashboardRailsSettings {
|
||||
favoriteMoviesAndSeries: boolean;
|
||||
recentSources: boolean;
|
||||
xtreamRecentlyAdded: boolean;
|
||||
/** TMDB "Trending this week" rail (needs the TMDB opt-in; Electron) */
|
||||
tmdbTrending: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_DASHBOARD_RAILS_SETTINGS: DashboardRailsSettings = {
|
||||
@@ -43,6 +45,7 @@ export const DEFAULT_DASHBOARD_RAILS_SETTINGS: DashboardRailsSettings = {
|
||||
favoriteMoviesAndSeries: true,
|
||||
recentSources: true,
|
||||
xtreamRecentlyAdded: true,
|
||||
tmdbTrending: true,
|
||||
};
|
||||
|
||||
export type DashboardRailsSettingsInput = Partial<
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './lib/dashboard-data.service';
|
||||
export * from './lib/dashboard-trending.service';
|
||||
@@ -0,0 +1,118 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
CatalogTitleMatchService,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
import type { TmdbTrendingEntry } from '@iptvnator/services';
|
||||
import { CatalogTitleMatch } from '@iptvnator/shared/interfaces';
|
||||
import { DashboardTrendingService } from './dashboard-trending.service';
|
||||
|
||||
describe('DashboardTrendingService', () => {
|
||||
const entry = (
|
||||
overrides: Partial<TmdbTrendingEntry> = {}
|
||||
): TmdbTrendingEntry => ({
|
||||
tmdbId: 603,
|
||||
mediaType: 'movie',
|
||||
title: 'The Matrix',
|
||||
year: 1999,
|
||||
posterUrl: null,
|
||||
rating: '8.2',
|
||||
popularity: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const match = (
|
||||
overrides: Partial<CatalogTitleMatch> = {}
|
||||
): CatalogTitleMatch => ({
|
||||
queryTitle: 'The Matrix',
|
||||
playlistId: 'pl-1',
|
||||
playlistName: 'My Portal',
|
||||
categoryId: 7,
|
||||
xtreamId: 42,
|
||||
type: 'movie',
|
||||
trailingYear: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let getTrendingWeek: jest.Mock;
|
||||
let matchTitles: jest.Mock;
|
||||
let isEnabled: jest.Mock;
|
||||
|
||||
function createService(
|
||||
options: { matchingAvailable?: boolean } = {}
|
||||
): DashboardTrendingService {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: { isEnabled, getTrendingWeek },
|
||||
},
|
||||
{
|
||||
provide: CatalogTitleMatchService,
|
||||
useValue: {
|
||||
isAvailable: options.matchingAvailable ?? true,
|
||||
matchTitles,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return TestBed.inject(DashboardTrendingService);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isEnabled = jest.fn().mockReturnValue(true);
|
||||
getTrendingWeek = jest.fn().mockResolvedValue([entry()]);
|
||||
matchTitles = jest.fn().mockResolvedValue([match()]);
|
||||
});
|
||||
|
||||
it('does nothing when TMDB is disabled', async () => {
|
||||
isEnabled.mockReturnValue(false);
|
||||
const service = createService();
|
||||
|
||||
await service.load();
|
||||
|
||||
expect(getTrendingWeek).not.toHaveBeenCalled();
|
||||
expect(service.items()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does nothing without the Electron title matcher (PWA)', async () => {
|
||||
const service = createService({ matchingAvailable: false });
|
||||
|
||||
await service.load();
|
||||
|
||||
expect(getTrendingWeek).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('attaches library matches to trending entries', async () => {
|
||||
const service = createService();
|
||||
|
||||
await service.load();
|
||||
|
||||
expect(service.items()).toHaveLength(1);
|
||||
expect(service.items()[0].match?.playlistName).toBe('My Portal');
|
||||
expect(service.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects year-incompatible base-tier matches', async () => {
|
||||
getTrendingWeek.mockResolvedValue([
|
||||
entry({ title: 'Blade Runner', year: 1982 }),
|
||||
]);
|
||||
matchTitles.mockResolvedValue([
|
||||
match({ queryTitle: 'Blade Runner', trailingYear: 2049 }),
|
||||
]);
|
||||
const service = createService();
|
||||
|
||||
await service.load();
|
||||
|
||||
expect(service.items()[0].match).toBeNull();
|
||||
});
|
||||
|
||||
it('loads only once per session', async () => {
|
||||
const service = createService();
|
||||
|
||||
await service.load();
|
||||
await service.load();
|
||||
|
||||
expect(getTrendingWeek).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import {
|
||||
CatalogTitleMatchService,
|
||||
TmdbEnrichmentService,
|
||||
buildTitleMatchIndex,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
CatalogTitleMatch,
|
||||
normalizeTitleKeys,
|
||||
titleYearsCompatible,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { TmdbTrendingEntry } from '@iptvnator/services';
|
||||
|
||||
/** One trending card: TMDB entry + optional library match for navigation */
|
||||
export interface DashboardTrendingItem extends TmdbTrendingEntry {
|
||||
/** Confident match in an imported Xtream playlist, when found */
|
||||
match: CatalogTitleMatch | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Trending this week" dashboard rail data. Requires the TMDB opt-in and
|
||||
* (for availability matching / navigation) the Electron DB worker — the
|
||||
* rail is hidden in the PWA. Trending lists are cached for a day, and the
|
||||
* title match runs as ONE batched worker request, fired only after the
|
||||
* dashboard's own data has loaded so it never competes for the worker.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardTrendingService {
|
||||
private readonly enrichment = inject(TmdbEnrichmentService);
|
||||
private readonly titleMatch = inject(CatalogTitleMatchService);
|
||||
|
||||
readonly items = signal<DashboardTrendingItem[]>([]);
|
||||
readonly loading = signal(false);
|
||||
|
||||
private loadedOnce = false;
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return this.enrichment.isEnabled() && this.titleMatch.isAvailable;
|
||||
}
|
||||
|
||||
/** Idempotent per app session — dashboard revisits reuse the signal */
|
||||
async load(): Promise<void> {
|
||||
if (this.loadedOnce || !this.isAvailable) {
|
||||
return;
|
||||
}
|
||||
this.loadedOnce = true;
|
||||
this.loading.set(true);
|
||||
|
||||
try {
|
||||
const entries = await this.enrichment.getTrendingWeek();
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = await this.titleMatch.matchTitles(
|
||||
entries.map((entry) => entry.title)
|
||||
);
|
||||
const index = buildTitleMatchIndex(matches);
|
||||
|
||||
this.items.set(
|
||||
entries.map((entry) => ({
|
||||
...entry,
|
||||
match: this.matchFor(entry, index),
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Dashboard trending load failed:', error);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private matchFor(
|
||||
entry: TmdbTrendingEntry,
|
||||
index: ReadonlyMap<string, CatalogTitleMatch>
|
||||
): CatalogTitleMatch | null {
|
||||
const type = entry.mediaType === 'movie' ? 'movie' : 'series';
|
||||
const key = `${type}:${normalizeTitleKeys(entry.title).exact}`;
|
||||
const match = index.get(key) ?? null;
|
||||
return match && titleYearsCompatible(entry.year, match.trailingYear)
|
||||
? match
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
TmdbEnrichmentService,
|
||||
extractYear,
|
||||
tmdbBackdropUrl,
|
||||
} from '@iptvnator/services';
|
||||
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
|
||||
|
||||
/** TMDB extras for the dashboard hero, patched in after first paint */
|
||||
export interface DashboardHeroTmdbExtras {
|
||||
readonly backdropUrl: string | null;
|
||||
readonly rating: string | null;
|
||||
readonly genres: readonly string[];
|
||||
}
|
||||
|
||||
const MAX_HERO_GENRES = 2;
|
||||
|
||||
/**
|
||||
* Best-effort TMDB extras for the hero card (backdrop, rating, genres).
|
||||
* Goes through the enrichment facade, so items already opened in a detail
|
||||
* view resolve from the SQLite cache without network. Results are memoized
|
||||
* per title for the session — dashboard revisits skip the IPC round-trip.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardHeroTmdbService {
|
||||
private readonly enrichment = inject(TmdbEnrichmentService);
|
||||
private readonly memo = new Map<
|
||||
string,
|
||||
Promise<DashboardHeroTmdbExtras | null>
|
||||
>();
|
||||
|
||||
getExtras(
|
||||
item: Pick<GlobalRecentItem, 'title' | 'type'>
|
||||
): Promise<DashboardHeroTmdbExtras | null> {
|
||||
if (
|
||||
!this.enrichment.isEnabled() ||
|
||||
(item.type !== 'movie' && item.type !== 'series')
|
||||
) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const key = `${item.type}:${item.title}`;
|
||||
const cached = this.memo.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const pending = this.loadExtras(item);
|
||||
this.memo.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async loadExtras(
|
||||
item: Pick<GlobalRecentItem, 'title' | 'type'>
|
||||
): Promise<DashboardHeroTmdbExtras | null> {
|
||||
try {
|
||||
const query = {
|
||||
title: item.title,
|
||||
year: extractYear(null, item.title),
|
||||
};
|
||||
const details =
|
||||
item.type === 'movie'
|
||||
? await this.enrichment.enrichMovie(query)
|
||||
: await this.enrichment.enrichTv(query);
|
||||
if (!details) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rating =
|
||||
(details.vote_count ?? 0) > 0 && details.vote_average
|
||||
? details.vote_average.toFixed(1)
|
||||
: null;
|
||||
const genres = (details.genres ?? [])
|
||||
.map((genre) => genre.name)
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_HERO_GENRES);
|
||||
|
||||
return {
|
||||
backdropUrl: tmdbBackdropUrl(details.backdrop_path),
|
||||
rating,
|
||||
genres,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Dashboard hero TMDB extras failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@ export interface DashboardHeroModel {
|
||||
readonly state?: Record<string, unknown>;
|
||||
readonly subtitle: string;
|
||||
readonly title: string;
|
||||
/** TMDB vote average ("8.1"), patched in async when available */
|
||||
readonly rating?: string | null;
|
||||
/** Up to two TMDB genre names, patched in async when available */
|
||||
readonly genres?: readonly string[];
|
||||
/** "S2·E5" for series with a tracked episode position */
|
||||
readonly episodeBadge?: string | null;
|
||||
/** 0-100 watched, when a resume position is known. */
|
||||
readonly watchProgress?: number | null;
|
||||
readonly remainingLabel?: DashboardRemainingLabel | null;
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<a
|
||||
class="rail__channel-link"
|
||||
[routerLink]="card.link"
|
||||
[queryParams]="card.queryParams ?? null"
|
||||
[state]="card.state"
|
||||
>
|
||||
<div class="rail__channel-row">
|
||||
@@ -141,6 +142,7 @@
|
||||
<a
|
||||
class="rail__card-link"
|
||||
[routerLink]="card.link"
|
||||
[queryParams]="card.queryParams ?? null"
|
||||
[state]="card.state"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface DashboardRailCard {
|
||||
icon: string;
|
||||
contentType?: 'live' | 'movie' | 'series';
|
||||
link: string[];
|
||||
queryParams?: Record<string, string>;
|
||||
state?: Record<string, unknown>;
|
||||
actions?: DashboardRailAction[];
|
||||
epgLookupKey?: string;
|
||||
|
||||
+54
@@ -77,6 +77,35 @@
|
||||
<h1 class="hero__title">{{ item.title }}</h1>
|
||||
<p class="hero__subtitle">{{ item.subtitle }}</p>
|
||||
|
||||
@if (
|
||||
item.episodeBadge ||
|
||||
item.rating ||
|
||||
(item.genres && item.genres.length > 0)
|
||||
) {
|
||||
<span
|
||||
class="hero__badges"
|
||||
data-test-id="dashboard-hero-badges"
|
||||
>
|
||||
@if (item.episodeBadge) {
|
||||
<span
|
||||
class="hero__badge hero__badge--episode"
|
||||
>{{ item.episodeBadge }}</span
|
||||
>
|
||||
}
|
||||
@if (item.rating) {
|
||||
<span
|
||||
class="hero__badge hero__badge--rating"
|
||||
>★ {{ item.rating }}</span
|
||||
>
|
||||
}
|
||||
@for (genre of item.genres; track genre) {
|
||||
<span class="hero__badge">{{
|
||||
genre
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (
|
||||
item.contentType === 'live' &&
|
||||
(item.nowPlayingTitle ||
|
||||
@@ -325,5 +354,30 @@
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- TMDB trending rail (opt-in; Electron only — needs title matching) -->
|
||||
@if (dashboardRails().tmdbTrending && trendingCards().length > 0) {
|
||||
<lib-dashboard-rail
|
||||
[label]="'WORKSPACE.DASHBOARD.TMDB_TRENDING' | translate"
|
||||
[items]="trendingCards()"
|
||||
[totalCount]="trendingCards().length"
|
||||
[testId]="'dashboard-tmdb-trending-rail'"
|
||||
/>
|
||||
} @else if (
|
||||
dashboardRails().tmdbTrending && trendingService.loading()
|
||||
) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="rails-page__skeleton-header"></div>
|
||||
<div class="rails-page__skeleton-track">
|
||||
@for (slot of skeletonSlots; track slot) {
|
||||
<div class="rails-page__skeleton-card"></div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
+44
@@ -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;
|
||||
|
||||
+115
-3
@@ -41,8 +41,12 @@ import {
|
||||
DashboardDataService,
|
||||
DashboardFavoriteItem,
|
||||
DashboardRecentlyAddedItem,
|
||||
DashboardTrendingItem,
|
||||
DashboardTrendingService,
|
||||
GlobalRecentItem,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import type { DashboardHeroTmdbExtras } from './dashboard-hero-tmdb.service';
|
||||
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
|
||||
import { DashboardRailComponent } from './dashboard-rail.component';
|
||||
import type {
|
||||
DashboardRailCard,
|
||||
@@ -115,6 +119,8 @@ export class WorkspaceDashboardRailsComponent {
|
||||
private readonly epgService = inject(EpgService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly heroTmdb = inject(DashboardHeroTmdbService);
|
||||
readonly trendingService = inject(DashboardTrendingService);
|
||||
|
||||
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
|
||||
readonly ready = this.data.dashboardReady;
|
||||
@@ -133,6 +139,12 @@ export class WorkspaceDashboardRailsComponent {
|
||||
() => this.data.globalRecentItems()[0] ?? null
|
||||
);
|
||||
|
||||
/** TMDB extras for the current hero item, patched in after first paint */
|
||||
private readonly heroTmdbExtras = signal<{
|
||||
key: string;
|
||||
extras: DashboardHeroTmdbExtras | null;
|
||||
} | null>(null);
|
||||
|
||||
private readonly heroLiveCard = computed<DashboardRailCard | null>(() => {
|
||||
const item = this.heroRecentItem();
|
||||
return item?.type === 'live' ? this.toRecentCard(item) : null;
|
||||
@@ -144,9 +156,14 @@ export class WorkspaceDashboardRailsComponent {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extrasForArtwork = this.heroTmdbExtras();
|
||||
const tmdbBackdrop =
|
||||
extrasForArtwork?.key === this.heroTmdbKey(item)
|
||||
? (extrasForArtwork.extras?.backdropUrl ?? null)
|
||||
: null;
|
||||
const artwork = resolveDashboardHeroArtwork(
|
||||
{
|
||||
backdropUrl: item.backdrop_url,
|
||||
backdropUrl: item.backdrop_url || tmdbBackdrop,
|
||||
posterUrl: item.poster_url,
|
||||
title: item.title,
|
||||
},
|
||||
@@ -158,6 +175,23 @@ export class WorkspaceDashboardRailsComponent {
|
||||
item.type === 'live'
|
||||
? this.getLiveEpgDetailsForCard(this.heroLiveCard())
|
||||
: null;
|
||||
const extrasState = this.heroTmdbExtras();
|
||||
const extras =
|
||||
extrasState?.key === this.heroTmdbKey(item)
|
||||
? extrasState.extras
|
||||
: null;
|
||||
const episodeBadge =
|
||||
item.type === 'series' &&
|
||||
position?.seasonNumber != null &&
|
||||
position?.episodeNumber != null
|
||||
? this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
|
||||
{
|
||||
season: position.seasonNumber,
|
||||
episode: position.episodeNumber,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...artwork,
|
||||
@@ -167,8 +201,13 @@ export class WorkspaceDashboardRailsComponent {
|
||||
state: this.data.getRecentItemNavigationState(item),
|
||||
subtitle: this.buildHeroSubtitle(item),
|
||||
title: item.title,
|
||||
watchProgress: playbackProgressPercent(position),
|
||||
remainingLabel: formatRemainingLabel(position),
|
||||
rating: extras?.rating ?? null,
|
||||
genres: extras?.genres ?? [],
|
||||
episodeBadge,
|
||||
watchProgress:
|
||||
item.type === 'live' ? null : playbackProgressPercent(position),
|
||||
remainingLabel:
|
||||
item.type === 'live' ? null : formatRemainingLabel(position),
|
||||
nowPlayingTitle: liveEpgDetails?.nowPlayingTitle ?? null,
|
||||
nowPlayingTimeRange: liveEpgDetails?.nowPlayingTimeRange ?? null,
|
||||
nowPlayingProgress: liveEpgDetails?.nowPlayingProgress ?? null,
|
||||
@@ -289,6 +328,13 @@ export class WorkspaceDashboardRailsComponent {
|
||||
.map((item) => this.toRecentlyAddedCard(item))
|
||||
);
|
||||
|
||||
readonly trendingCards = computed<DashboardRailCard[]>(() => {
|
||||
this.languageTick();
|
||||
return this.trendingService
|
||||
.items()
|
||||
.map((item) => this.toTrendingCard(item));
|
||||
});
|
||||
|
||||
readonly sourceCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data.recentPlaylists().map((playlist) => ({
|
||||
id: playlist._id,
|
||||
@@ -338,6 +384,40 @@ export class WorkspaceDashboardRailsComponent {
|
||||
this.playbackPositionReloadKey();
|
||||
untracked(() => void this.data.reloadPlaybackPositions());
|
||||
});
|
||||
|
||||
// TMDB extras for the hero (backdrop/rating/genres) — async after
|
||||
// first paint, staleness-guarded against hero changes in flight.
|
||||
effect(() => {
|
||||
const item = this.heroRecentItem();
|
||||
if (!item || (item.type !== 'movie' && item.type !== 'series')) {
|
||||
return;
|
||||
}
|
||||
const key = this.heroTmdbKey(item);
|
||||
untracked(() => {
|
||||
if (this.heroTmdbExtras()?.key === key) {
|
||||
return;
|
||||
}
|
||||
void this.heroTmdb.getExtras(item).then((extras) => {
|
||||
const current = untracked(() => this.heroRecentItem());
|
||||
if (current && this.heroTmdbKey(current) === key) {
|
||||
this.heroTmdbExtras.set({ key, extras });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Trending rail: needs the TMDB opt-in and the Electron DB worker.
|
||||
// Deferred until the dashboard's own recent/favorites data is in so
|
||||
// the batched title match never competes for the worker at startup.
|
||||
effect(() => {
|
||||
if (
|
||||
!this.dashboardRails().tmdbTrending ||
|
||||
!this.data.globalFavoritesLoaded()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
untracked(() => void this.trendingService.load());
|
||||
});
|
||||
}
|
||||
|
||||
onAddPlaylist(type?: WorkspacePlaylistType): void {
|
||||
@@ -481,6 +561,38 @@ export class WorkspaceDashboardRailsComponent {
|
||||
};
|
||||
}
|
||||
|
||||
private heroTmdbKey(item: GlobalRecentItem): string {
|
||||
return `${item.type}:${item.title}`;
|
||||
}
|
||||
|
||||
private toTrendingCard(item: DashboardTrendingItem): DashboardRailCard {
|
||||
const subtitle = [
|
||||
item.year !== null ? String(item.year) : null,
|
||||
item.rating ? `★ ${item.rating}` : null,
|
||||
item.match?.playlistName ?? null,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' · ');
|
||||
return {
|
||||
id: `trending-${item.mediaType}-${item.tmdbId}`,
|
||||
title: item.title,
|
||||
subtitle,
|
||||
imageUrl: item.posterUrl ?? undefined,
|
||||
icon: item.mediaType === 'movie' ? 'movie' : 'video_library',
|
||||
contentType: item.mediaType === 'movie' ? 'movie' : 'series',
|
||||
link: item.match
|
||||
? [
|
||||
'/workspace/xtreams',
|
||||
item.match.playlistId,
|
||||
item.match.type === 'movie' ? 'vod' : 'series',
|
||||
String(item.match.categoryId),
|
||||
String(item.match.xtreamId),
|
||||
]
|
||||
: ['/workspace/search'],
|
||||
...(item.match ? {} : { queryParams: { q: item.title } }),
|
||||
};
|
||||
}
|
||||
|
||||
private typeIcon(type: 'live' | 'movie' | 'series'): string {
|
||||
if (type === 'live') return 'live_tv';
|
||||
if (type === 'movie') return 'movie';
|
||||
|
||||
Reference in new issue
Block a user