mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat(tmdb): dashboard trending + hero enrichment, cross-portal Similar rail (#1132)
* feat(dashboard): TMDB trending rail and hero enrichment (backdrop, badges, S/E)
Two dashboard additions, both async after first paint so the page renders
exactly as fast as before:
Trending rail ("Trending this week", dashboardRails.tmdbTrending toggle,
default on, rendered only when TMDB is opted in AND the Electron DB
worker is available):
- TmdbTrendingService fetches /trending/{movie,tv}/week (one request
each, cached one day per language in tmdb_metadata under
trending:week), merges by popularity; exposed via the enrichment
facade (getTrendingWeek)
- DashboardTrendingService matches the titles against imported Xtream
playlists with ONE batched DB_MATCH_TITLES request, applying the same
two-tier + year-compatibility rule as actor pages; matched cards show
the playlist name and navigate straight to the detail view, unmatched
cards open the global search prefilled (?q=)
- The load fires only after the dashboard's own recent/favorites data
is in (never competes for the worker at startup) and once per session
- DashboardRailCard gained optional queryParams for the search links
Hero enrichment:
- DashboardHeroTmdbService patches the hero with a TMDB backdrop (only
when the item has none), a rating badge and up to two genre chips —
via the enrichment facade, so previously opened items resolve from
the SQLite cache without network; memoized per title per session,
staleness-guarded against hero changes in flight
- Series heroes show the tracked "S{n}·E{n}" badge from the playback
position; the watch-progress bar no longer applies to live heroes
Settings: new dashboardRails.tmdbTrending toggle in Settings > Dashboard.
i18n: 3 new keys translated into all 17 locales via tools/i18n patches.
Tests: dashboard-trending.service.spec.ts (gating, matching, year guard,
single-flight); settings fixtures updated. Docs updated
(tmdb-metadata-enrichment.md Dashboard Integration section, CLAUDE.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): resolve hero TMDB extras for Stalker embedded-series items
Stalker vclub items carry type 'movie' in activity rows but are TV shows
on TMDB, so the hero's movie lookup found no confident match and the
backdrop/badges never appeared — while the detail view (which resolves
via is_series) showed them. When a movie-typed hero item has no movie
match, retry the lookup as TV: the detail view has usually already
cached that resolution, and misses are negative-cached.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tmdb): cross-portal "Similar" rail — Stalker gets it, Xtream gains other-portal matches
The Similar rail only existed on Xtream because it matched against the
locally loaded catalog; Stalker catalogs are server-paginated, so its
detail views had no rail despite tmdb_recommendations being cached.
New CrossPortalSimilarService (libs/services) matches recommendations
against ALL imported Xtream playlists with one batched DB_MATCH_TITLES
worker request — the same two-tier normalized-title + year-compatibility
rule as actor pages and the trending rail. Electron-only; resolves to []
in the PWA.
- Stalker: the shared VodDetailsComponent (movies; covers catalog and
inline detail hosts) and stalker-series-view (series) now render a
"Similar" rail from cross-portal matches, each card badged with the
source playlist and navigating into that portal's detail view.
- Xtream: vod/serial detail rails keep instant local-catalog matches and
append cross-portal matches (current playlist excluded, deduplicated
against local hits by normalized exact title), also playlist-badged.
- Loads async after the detail view renders, staleness-guarded; the
section only appears when there is something to show.
Tests: cross-portal-similar.service.spec.ts (PWA gate, navigation
targets, playlist exclusion, type/year guards). Docs updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tmdb): drop TestBed from cross-portal similar spec
The services Jest target has no @angular/core/testing (same CI failure
as the cache spec earlier) — construct the service via Injector.create +
runInInjectionContext instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): address PR review — reactive opt-out gates, retry after empty trending load
- Trending rail and hero TMDB extras now vanish immediately when the
TMDB opt-in is switched off mid-session: the render computeds read the
settings signal through isAvailable/isEnabled instead of trusting data
loaded earlier (Codex P2 ×2).
- loadedOnce latches only after a successful non-empty load, so a
transient TMDB outage on first visit no longer suppresses the rail for
the whole session — the next dashboard visit retries (greptile P2).
- Unified the duplicated heroTmdbExtras() read in the hero computed
(greptile P2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9635f398c1
commit
862b271eac
55 files changed
+1467
-21
No files matched your search
@@ -645,8 +645,9 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
**TMDB Metadata Enrichment** (opt-in):
|
||||
|
||||
- Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works
|
||||
- "Similar" rail in Xtream detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation
|
||||
- "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation
|
||||
- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`)
|
||||
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint
|
||||
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
|
||||
- Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts`
|
||||
- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)
|
||||
|
||||
@@ -160,5 +160,23 @@
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="tmdbTrending"
|
||||
data-test-id="toggle-dashboard-rail-tmdb-trending"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -50,6 +50,7 @@ export function createSettingsForm(
|
||||
recentSources: DEFAULT_DASHBOARD_RAILS_SETTINGS.recentSources,
|
||||
xtreamRecentlyAdded:
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS.xtreamRecentlyAdded,
|
||||
tmdbTrending: DEFAULT_DASHBOARD_RAILS_SETTINGS.tmdbTrending,
|
||||
}),
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
showExternalPlaybackBar: true,
|
||||
|
||||
@@ -69,6 +69,7 @@ const DEFAULT_DASHBOARD_RAILS = {
|
||||
favoriteMoviesAndSeries: true,
|
||||
recentSources: true,
|
||||
xtreamRecentlyAdded: true,
|
||||
tmdbTrending: true,
|
||||
};
|
||||
|
||||
class MatSnackBarStub {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -123,9 +124,17 @@ year tag is compatible (±1) with the TMDB year, so "Blade Runner" (1982)
|
||||
never claims a catalog "Blade Runner 2049". The rail navigates
|
||||
to the matched item — the detail components re-initialize on route param
|
||||
changes (reactive `routeParams` signal) because the router reuses the
|
||||
component for detail→detail navigation. Stalker gets trailers and the
|
||||
`tmdb_recommendations` data, but no rail yet (its catalog is
|
||||
server-paginated, so there is no local list to match against).
|
||||
component for detail→detail navigation.
|
||||
|
||||
`CrossPortalSimilarService` (`libs/services`) extends the rail across
|
||||
portals: recommendations are matched against ALL imported Xtream
|
||||
playlists with one batched `DB_MATCH_TITLES` request (Electron only,
|
||||
same two-tier + year rule). Stalker detail views — where the local
|
||||
catalog is server-paginated and unmatchable — get their "Similar" rail
|
||||
purely from these cross-portal matches (shared `VodDetailsComponent` for
|
||||
movies, `stalker-series-view` for series); Xtream detail views append
|
||||
them after the local-catalog matches, deduplicated by normalized title,
|
||||
with the source playlist name on each card.
|
||||
The `language` param derives from the app language setting
|
||||
(`Language` enum → TMDB code, e.g. `de` → `de-DE`); cache rows are keyed per
|
||||
language, so switching the app language re-fetches localized metadata.
|
||||
@@ -262,3 +271,27 @@ never blocks or delays rendering of the detail view.
|
||||
Similar/recommendations rails, actor cross-catalog search, trending
|
||||
dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache
|
||||
(IndexedDB).
|
||||
|
||||
## Dashboard Integration
|
||||
|
||||
- **Trending rail** ("Trending this week", `dashboardRails.tmdbTrending`
|
||||
toggle): `DashboardTrendingService`
|
||||
(`libs/workspace/dashboard/data-access`) pulls the weekly TMDB trending
|
||||
lists (cached one day per language) and runs ONE batched
|
||||
`CatalogTitleMatchService.matchTitles` request against the imported
|
||||
Xtream playlists — matched cards navigate straight to their detail view
|
||||
and show the playlist name; unmatched cards open the global search
|
||||
prefilled. Requires both the TMDB opt-in and the Electron DB worker
|
||||
(the rail is hidden in the PWA). The load fires only after the
|
||||
dashboard's own recent/favorites data is in, so it never competes for
|
||||
the worker at startup, and runs once per app session.
|
||||
- **Hero extras**: `DashboardHeroTmdbService`
|
||||
(`libs/workspace/dashboard/feature`) patches the hero card with a TMDB
|
||||
backdrop (when the provider item has none), a rating badge and up to two
|
||||
genre chips — resolved through the enrichment facade, so items already
|
||||
opened in a detail view come from the SQLite cache without network.
|
||||
Results are memoized per title for the session. The hero renders
|
||||
immediately from provider data; extras appear when resolved. Series
|
||||
heroes additionally show the tracked "S{n}·E{n}" badge from the playback
|
||||
position (no TMDB involved); the watch-progress bar is limited to
|
||||
movie/series heroes.
|
||||
+47
@@ -166,6 +166,53 @@
|
||||
}
|
||||
|
||||
<div detail-extras>
|
||||
@if (similarInPortals().length) {
|
||||
<section
|
||||
class="details__similar"
|
||||
data-test-id="tmdb-similar"
|
||||
>
|
||||
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
|
||||
<div class="details__similar-strip">
|
||||
@for (
|
||||
item of similarInPortals();
|
||||
track item.match.xtreamId
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__similar-card"
|
||||
[title]="
|
||||
item.title +
|
||||
' — ' +
|
||||
item.match.playlistName
|
||||
"
|
||||
(click)="openSimilarInPortals(item)"
|
||||
>
|
||||
@if (item.posterUrl) {
|
||||
<img
|
||||
class="details__similar-poster"
|
||||
[src]="item.posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__similar-poster details__similar-poster--fallback"
|
||||
>
|
||||
{{ item.title.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__similar-title">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="details__similar-source">
|
||||
{{ item.match.playlistName }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- YouTube Trailer (TMDB enrichment; Stalker provides none) -->
|
||||
@if (trailerEmbedUrl(); as trailerUrl) {
|
||||
<div class="youtube-trailer">
|
||||
|
||||
+38
@@ -62,6 +62,8 @@ import {
|
||||
type SeriesPlaybackEpisodeState,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import {
|
||||
CrossPortalSimilarItem,
|
||||
CrossPortalSimilarService,
|
||||
DownloadsService,
|
||||
PlaybackPositionRuntimeBridgeService,
|
||||
} from '@iptvnator/services';
|
||||
@@ -128,6 +130,38 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
readonly selectedItem = this.stalkerStore.selectedItem;
|
||||
|
||||
private readonly tmdbSeasons = inject(StalkerSeriesTmdbSeasonsService);
|
||||
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
|
||||
|
||||
/**
|
||||
* TMDB recommendations found in the user's Xtream portals (batched DB
|
||||
* match, Electron only) — Stalker catalogs are server-paginated, so
|
||||
* "Similar" can only point at OTHER portals' libraries.
|
||||
*/
|
||||
readonly similarInPortals = signal<CrossPortalSimilarItem[]>([]);
|
||||
private readonly loadSimilarInPortals = effect(() => {
|
||||
const recommendations =
|
||||
this.displayItem()?.info?.tmdb_recommendations;
|
||||
untracked(() => {
|
||||
this.similarInPortals.set([]);
|
||||
if (
|
||||
!recommendations?.length ||
|
||||
!this.crossPortalSimilar.isAvailable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void this.crossPortalSimilar
|
||||
.matchRecommendations(recommendations, 'series')
|
||||
.then((items) => {
|
||||
if (
|
||||
this.displayItem()?.info?.tmdb_recommendations ===
|
||||
recommendations
|
||||
) {
|
||||
this.similarInPortals.set(items);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Season currently selected in the season container. */
|
||||
private readonly selectedSeasonKey = signal<string | null>(null);
|
||||
|
||||
@@ -531,6 +565,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
openSimilarInPortals(item: CrossPortalSimilarItem): void {
|
||||
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
|
||||
}
|
||||
|
||||
openActor(member: TmdbEnrichedCastMember): void {
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id;
|
||||
if (!playlistId || !member.tmdbPersonId) {
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
/>
|
||||
|
||||
<div detail-extras>
|
||||
@if (similarItems().length) {
|
||||
@if (similarItems().length || similarInPortals().length) {
|
||||
<section class="details__similar" data-test-id="tmdb-similar">
|
||||
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
|
||||
<div class="details__similar-strip">
|
||||
@@ -195,6 +195,42 @@
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@for (
|
||||
item of similarInPortals();
|
||||
track 'x' + item.match.playlistId + '-' + item.match.xtreamId
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__similar-card"
|
||||
[title]="
|
||||
item.title +
|
||||
' — ' +
|
||||
item.match.playlistName
|
||||
"
|
||||
(click)="openSimilarInPortals(item)"
|
||||
>
|
||||
@if (item.posterUrl) {
|
||||
<img
|
||||
class="details__similar-poster"
|
||||
[src]="item.posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__similar-poster details__similar-poster--fallback"
|
||||
>
|
||||
{{ item.title.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__similar-title">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="details__similar-source">
|
||||
{{ item.match.playlistName }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -33,7 +33,12 @@ import {
|
||||
XtreamSerieInfo,
|
||||
XtreamSerieEpisode,
|
||||
XtreamSerieDetails,
|
||||
normalizeTitleKeys,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
CrossPortalSimilarItem,
|
||||
CrossPortalSimilarService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
SerialDetailsPlaybackService,
|
||||
type XtreamSerieDetailsView,
|
||||
@@ -76,6 +81,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
private readonly location = inject(Location);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly playback = inject(SerialDetailsPlaybackService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
@@ -138,6 +144,46 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
});
|
||||
|
||||
/** Recommendations found in the user's OTHER portals (Electron only) */
|
||||
private readonly crossPortalItems = signal<CrossPortalSimilarItem[]>([]);
|
||||
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
|
||||
const localTitles = new Set(
|
||||
this.similarItems().map(
|
||||
(item) => normalizeTitleKeys(item.title).exact
|
||||
)
|
||||
);
|
||||
return this.crossPortalItems().filter(
|
||||
(item) => !localTitles.has(normalizeTitleKeys(item.title).exact)
|
||||
);
|
||||
});
|
||||
|
||||
private readonly loadCrossPortalSimilar = effect(() => {
|
||||
const recommendations =
|
||||
this.selectedItem()?.info?.tmdb_recommendations;
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
untracked(() => {
|
||||
this.crossPortalItems.set([]);
|
||||
if (
|
||||
!recommendations?.length ||
|
||||
!this.crossPortalSimilar.isAvailable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void this.crossPortalSimilar
|
||||
.matchRecommendations(recommendations, 'series', {
|
||||
excludePlaylistId: playlistId,
|
||||
})
|
||||
.then((items) => {
|
||||
if (
|
||||
this.selectedItem()?.info?.tmdb_recommendations ===
|
||||
recommendations
|
||||
) {
|
||||
this.crossPortalItems.set(items);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.playback.bind({ selectedItem: this.selectedItem });
|
||||
|
||||
@@ -246,6 +292,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
this.xtreamStore.setSelectedItem(null);
|
||||
}
|
||||
|
||||
openSimilarInPortals(item: CrossPortalSimilarItem): void {
|
||||
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
|
||||
}
|
||||
|
||||
openSimilar(item: SimilarCatalogItem): void {
|
||||
void this.router.navigate(['../..', item.categoryId, item.id], {
|
||||
relativeTo: this.route,
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (similarItems().length) {
|
||||
@if (similarItems().length || similarInPortals().length) {
|
||||
<section class="details__similar" data-test-id="tmdb-similar">
|
||||
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
|
||||
<div class="details__similar-strip">
|
||||
@@ -267,6 +267,42 @@
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@for (
|
||||
item of similarInPortals();
|
||||
track 'x' + item.match.playlistId + '-' + item.match.xtreamId
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__similar-card"
|
||||
[title]="
|
||||
item.title +
|
||||
' — ' +
|
||||
item.match.playlistName
|
||||
"
|
||||
(click)="openSimilarInPortals(item)"
|
||||
>
|
||||
@if (item.posterUrl) {
|
||||
<img
|
||||
class="details__similar-poster"
|
||||
[src]="item.posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__similar-poster details__similar-poster--fallback"
|
||||
>
|
||||
{{ item.title.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__similar-title">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="details__similar-source">
|
||||
{{ item.match.playlistName }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -27,14 +28,20 @@ import {
|
||||
type PlaybackFallbackRequest,
|
||||
PortalInlinePlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { DownloadsService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
XtreamCategory,
|
||||
CrossPortalSimilarItem,
|
||||
CrossPortalSimilarService,
|
||||
DownloadsService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
getXtreamVodInfo,
|
||||
normalizeTitleKeys,
|
||||
TmdbEnrichedCastMember,
|
||||
XtreamCategory,
|
||||
XtreamVodDetails,
|
||||
XtreamVodInfo,
|
||||
XtreamVodStream,
|
||||
getXtreamVodInfo,
|
||||
youtubeEmbedUrl,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
@@ -72,6 +79,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
@@ -228,6 +236,45 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
});
|
||||
|
||||
/** Recommendations found in the user's OTHER portals (Electron only) */
|
||||
private readonly crossPortalItems = signal<CrossPortalSimilarItem[]>([]);
|
||||
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
|
||||
const localTitles = new Set(
|
||||
this.similarItems().map(
|
||||
(item) => normalizeTitleKeys(item.title).exact
|
||||
)
|
||||
);
|
||||
return this.crossPortalItems().filter(
|
||||
(item) => !localTitles.has(normalizeTitleKeys(item.title).exact)
|
||||
);
|
||||
});
|
||||
|
||||
private readonly loadCrossPortalSimilar = effect(() => {
|
||||
const recommendations = this.selectedVodInfo()?.tmdb_recommendations;
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
untracked(() => {
|
||||
this.crossPortalItems.set([]);
|
||||
if (
|
||||
!recommendations?.length ||
|
||||
!this.crossPortalSimilar.isAvailable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void this.crossPortalSimilar
|
||||
.matchRecommendations(recommendations, 'movie', {
|
||||
excludePlaylistId: playlistId,
|
||||
})
|
||||
.then((items) => {
|
||||
if (
|
||||
this.selectedVodInfo()?.tmdb_recommendations ===
|
||||
recommendations
|
||||
) {
|
||||
this.crossPortalItems.set(items);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.playback.bind({
|
||||
vodId: this.selectedVodId,
|
||||
@@ -288,6 +335,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
openSimilarInPortals(item: CrossPortalSimilarItem): void {
|
||||
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
|
||||
}
|
||||
|
||||
openSimilar(item: SimilarCatalogItem): void {
|
||||
void this.router.navigate(['../..', item.categoryId, item.id], {
|
||||
relativeTo: this.route,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './lib/catalog-title-match.service';
|
||||
export * from './lib/cross-portal-similar.service';
|
||||
export * from './lib/data.service';
|
||||
export * from './lib/database-electron.service';
|
||||
export * from './lib/downloads.service';
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { CatalogTitleMatch } from '@iptvnator/shared/interfaces';
|
||||
import { CatalogTitleMatchService } from './catalog-title-match.service';
|
||||
import { CrossPortalSimilarService } from './cross-portal-similar.service';
|
||||
|
||||
describe('CrossPortalSimilarService', () => {
|
||||
const rec = (title: string, year: number | null = 1999) => ({
|
||||
tmdbId: 1,
|
||||
title,
|
||||
year,
|
||||
posterUrl: null,
|
||||
});
|
||||
|
||||
const match = (
|
||||
overrides: Partial<CatalogTitleMatch> = {}
|
||||
): CatalogTitleMatch => ({
|
||||
queryTitle: 'The Matrix',
|
||||
playlistId: 'pl-1',
|
||||
playlistName: 'Portal One',
|
||||
categoryId: 7,
|
||||
xtreamId: 42,
|
||||
type: 'movie',
|
||||
trailingYear: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let matchTitles: jest.Mock;
|
||||
let isAvailable: boolean;
|
||||
|
||||
// The services Jest target has no @angular/core/testing — construct
|
||||
// the service in a plain injection context instead of TestBed
|
||||
function createService(): CrossPortalSimilarService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: CatalogTitleMatchService,
|
||||
useValue: {
|
||||
get isAvailable() {
|
||||
return isAvailable;
|
||||
},
|
||||
matchTitles,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return runInInjectionContext(
|
||||
injector,
|
||||
() => new CrossPortalSimilarService()
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isAvailable = true;
|
||||
matchTitles = jest.fn().mockResolvedValue([match()]);
|
||||
});
|
||||
|
||||
it('resolves to [] in the PWA', async () => {
|
||||
isAvailable = false;
|
||||
const service = createService();
|
||||
await expect(
|
||||
service.matchRecommendations([rec('The Matrix')], 'movie')
|
||||
).resolves.toEqual([]);
|
||||
expect(matchTitles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns matched recommendations with navigation targets', async () => {
|
||||
const service = createService();
|
||||
const items = await service.matchRecommendations(
|
||||
[rec('The Matrix'), rec('Unmatched Title')],
|
||||
'movie'
|
||||
);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].match.playlistName).toBe('Portal One');
|
||||
expect(service.buildLink(items[0])).toEqual([
|
||||
'/workspace/xtreams',
|
||||
'pl-1',
|
||||
'vod',
|
||||
'7',
|
||||
'42',
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes the current playlist but keeps other playlists', async () => {
|
||||
matchTitles.mockResolvedValue([
|
||||
match({ playlistId: 'current' }),
|
||||
match({ playlistId: 'other', playlistName: 'Portal Two' }),
|
||||
]);
|
||||
const service = createService();
|
||||
|
||||
const items = await service.matchRecommendations(
|
||||
[rec('The Matrix')],
|
||||
'movie',
|
||||
{ excludePlaylistId: 'current' }
|
||||
);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].match.playlistId).toBe('other');
|
||||
});
|
||||
|
||||
it('drops type mismatches and year-incompatible matches', async () => {
|
||||
matchTitles.mockResolvedValue([
|
||||
match({ type: 'series' }),
|
||||
match({ queryTitle: 'Blade Runner', trailingYear: 2049 }),
|
||||
]);
|
||||
const service = createService();
|
||||
|
||||
const items = await service.matchRecommendations(
|
||||
[rec('The Matrix'), rec('Blade Runner', 1982)],
|
||||
'movie'
|
||||
);
|
||||
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
CatalogTitleMatch,
|
||||
TmdbRecommendation,
|
||||
normalizeTitleKeys,
|
||||
titleYearsCompatible,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
CatalogTitleMatchService,
|
||||
buildTitleMatchIndex,
|
||||
} from './catalog-title-match.service';
|
||||
|
||||
/** One TMDB recommendation found in an imported Xtream playlist */
|
||||
export interface CrossPortalSimilarItem {
|
||||
title: string;
|
||||
posterUrl: string | null;
|
||||
year: number | null;
|
||||
/** Where to navigate: playlist + category + item in that portal */
|
||||
match: CatalogTitleMatch;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 12;
|
||||
|
||||
/**
|
||||
* Matches TMDB recommendations against ALL imported Xtream playlists via
|
||||
* one batched DB-worker request — powers the "Similar" rail for portals
|
||||
* without a matchable local catalog (Stalker) and supplements the Xtream
|
||||
* rail with titles available in the user's other portals. Electron-only:
|
||||
* `isAvailable` is false in the PWA and every call resolves to [].
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CrossPortalSimilarService {
|
||||
private readonly titleMatch = inject(CatalogTitleMatchService);
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return this.titleMatch.isAvailable;
|
||||
}
|
||||
|
||||
async matchRecommendations(
|
||||
recommendations: readonly TmdbRecommendation[] | undefined,
|
||||
type: 'movie' | 'series',
|
||||
options: { excludePlaylistId?: string; limit?: number } = {}
|
||||
): Promise<CrossPortalSimilarItem[]> {
|
||||
if (!recommendations?.length || !this.isAvailable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matches = await this.titleMatch.matchTitles(
|
||||
recommendations.map((recommendation) => recommendation.title)
|
||||
);
|
||||
// Filter before indexing so a title also present in another
|
||||
// playlist survives the exclusion of the current one
|
||||
const index = buildTitleMatchIndex(
|
||||
matches.filter(
|
||||
(match) =>
|
||||
match.type === type &&
|
||||
match.playlistId !== options.excludePlaylistId
|
||||
)
|
||||
);
|
||||
|
||||
const limit = options.limit ?? DEFAULT_LIMIT;
|
||||
const seen = new Set<string>();
|
||||
const items: CrossPortalSimilarItem[] = [];
|
||||
for (const recommendation of recommendations) {
|
||||
if (items.length >= limit) {
|
||||
break;
|
||||
}
|
||||
const key = `${type}:${normalizeTitleKeys(recommendation.title).exact}`;
|
||||
const match = index.get(key);
|
||||
if (
|
||||
!match ||
|
||||
!titleYearsCompatible(recommendation.year, match.trailingYear)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const dedupeKey = `${match.playlistId}:${match.type}:${match.xtreamId}`;
|
||||
if (seen.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(dedupeKey);
|
||||
items.push({
|
||||
title: recommendation.title,
|
||||
posterUrl: recommendation.posterUrl,
|
||||
year: recommendation.year,
|
||||
match,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Route array for one match: the item's detail view in its portal */
|
||||
buildLink(item: CrossPortalSimilarItem): string[] {
|
||||
return [
|
||||
'/workspace/xtreams',
|
||||
item.match.playlistId,
|
||||
item.match.type === 'movie' ? 'vod' : 'series',
|
||||
String(item.match.categoryId),
|
||||
String(item.match.xtreamId),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const expectedDashboardRails = (
|
||||
favoriteMoviesAndSeries: true,
|
||||
recentSources: true,
|
||||
xtreamRecentlyAdded: true,
|
||||
tmdbTrending: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -76,6 +76,20 @@ export class TmdbApiService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Weekly trending titles for one media type */
|
||||
async getTrending(
|
||||
mediaType: 'movie' | 'tv',
|
||||
language: string,
|
||||
apiKey: string
|
||||
): Promise<TmdbSearchResult[]> {
|
||||
const response = await this.request<TmdbSearchResponse>(
|
||||
`/trending/${mediaType}/week`,
|
||||
{ language },
|
||||
apiKey
|
||||
);
|
||||
return response.results ?? [];
|
||||
}
|
||||
|
||||
async getPersonDetails(
|
||||
personId: number,
|
||||
language: string,
|
||||
|
||||
@@ -26,6 +26,9 @@ export const TMDB_MATCH_CACHE_TTL_MS = 30 * DAY_MS;
|
||||
/** Negative matches retry sooner — providers fix titles, TMDB adds entries */
|
||||
export const TMDB_NEGATIVE_MATCH_CACHE_TTL_MS = 7 * DAY_MS;
|
||||
|
||||
/** Weekly trending rotates slowly — refresh the dashboard rail daily */
|
||||
export const TMDB_TRENDING_CACHE_TTL_MS = 1 * DAY_MS;
|
||||
|
||||
/** App language → TMDB language-region code for localized metadata */
|
||||
const TMDB_LANGUAGE_MAP: Record<Language, string> = {
|
||||
[Language.ARABIC]: 'ar-SA',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { TmdbPersonService } from './tmdb-person.service';
|
||||
import { TmdbRuntimeService } from './tmdb-runtime.service';
|
||||
import { TmdbSeasonService } from './tmdb-season.service';
|
||||
import { TmdbTrendingService } from './tmdb-trending.service';
|
||||
import {
|
||||
TmdbDetails,
|
||||
TmdbEnrichmentQuery,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
TmdbMovieDetails,
|
||||
TmdbPersonDetails,
|
||||
TmdbSeasonDetails,
|
||||
TmdbTrendingEntry,
|
||||
TmdbTvDetails,
|
||||
} from './tmdb.types';
|
||||
|
||||
@@ -50,6 +52,7 @@ export class TmdbEnrichmentService {
|
||||
private readonly cache = inject(TmdbCacheService);
|
||||
private readonly person = inject(TmdbPersonService);
|
||||
private readonly season = inject(TmdbSeasonService);
|
||||
private readonly trending = inject(TmdbTrendingService);
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.runtime.isEnabled();
|
||||
@@ -84,6 +87,11 @@ export class TmdbEnrichmentService {
|
||||
return this.season.getSeason(tmdbId, seasonNumber);
|
||||
}
|
||||
|
||||
/** Weekly trending titles (movies + series merged by popularity) */
|
||||
async getTrendingWeek(limit?: number): Promise<TmdbTrendingEntry[]> {
|
||||
return this.trending.getTrendingWeek(limit);
|
||||
}
|
||||
|
||||
async getPersonDetails(
|
||||
personId: number
|
||||
): Promise<TmdbPersonDetails | null> {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { TmdbApiService } from './tmdb-api.service';
|
||||
import { TmdbCacheService } from './tmdb-cache.service';
|
||||
import {
|
||||
TMDB_TRENDING_CACHE_TTL_MS,
|
||||
tmdbPosterUrl,
|
||||
} from './tmdb-config';
|
||||
import { extractYear } from './tmdb-matcher';
|
||||
import { TmdbRuntimeService } from './tmdb-runtime.service';
|
||||
import { TmdbSearchResult, TmdbTrendingEntry } from './tmdb.types';
|
||||
|
||||
/**
|
||||
* Weekly TMDB trending for the dashboard rail. Movie and TV lists are
|
||||
* fetched separately (one request each, cached for a day per language)
|
||||
* and merged by popularity.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TmdbTrendingService {
|
||||
private readonly runtime = inject(TmdbRuntimeService);
|
||||
private readonly api = inject(TmdbApiService);
|
||||
private readonly cache = inject(TmdbCacheService);
|
||||
|
||||
async getTrendingWeek(limit = 18): Promise<TmdbTrendingEntry[]> {
|
||||
if (!this.runtime.isEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [movies, tv] = await Promise.all([
|
||||
this.getTrendingFor('movie'),
|
||||
this.getTrendingFor('tv'),
|
||||
]);
|
||||
|
||||
return [...movies, ...tv]
|
||||
.sort((a, b) => b.popularity - a.popularity)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
private async getTrendingFor(
|
||||
mediaType: 'movie' | 'tv'
|
||||
): Promise<TmdbTrendingEntry[]> {
|
||||
try {
|
||||
const language = this.runtime.language();
|
||||
const lookupKey = 'trending:week';
|
||||
|
||||
const cached = await this.cache.get(
|
||||
mediaType,
|
||||
lookupKey,
|
||||
language
|
||||
);
|
||||
if (
|
||||
this.cache.isFresh(cached, TMDB_TRENDING_CACHE_TTL_MS) &&
|
||||
cached?.payload
|
||||
) {
|
||||
try {
|
||||
return this.toEntries(
|
||||
JSON.parse(cached.payload) as TmdbSearchResult[],
|
||||
mediaType
|
||||
);
|
||||
} catch {
|
||||
// Corrupt cache row — fall through to a fresh fetch
|
||||
}
|
||||
}
|
||||
|
||||
const results = await this.api.getTrending(
|
||||
mediaType,
|
||||
language,
|
||||
this.runtime.apiKey()
|
||||
);
|
||||
|
||||
await this.cache.set({
|
||||
mediaType,
|
||||
lookupKey,
|
||||
language,
|
||||
tmdbId: null,
|
||||
payload: JSON.stringify(results),
|
||||
});
|
||||
|
||||
return this.toEntries(results, mediaType);
|
||||
} catch (error) {
|
||||
console.warn(`TMDB trending (${mediaType}) failed:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private toEntries(
|
||||
results: TmdbSearchResult[],
|
||||
mediaType: 'movie' | 'tv'
|
||||
): TmdbTrendingEntry[] {
|
||||
return results
|
||||
.map((result) => {
|
||||
const title = result.title ?? result.name ?? '';
|
||||
const rating =
|
||||
(result.vote_count ?? 0) > 0 && result.vote_average
|
||||
? result.vote_average.toFixed(1)
|
||||
: null;
|
||||
return {
|
||||
tmdbId: result.id,
|
||||
mediaType,
|
||||
title,
|
||||
year: extractYear(
|
||||
result.release_date ?? result.first_air_date
|
||||
),
|
||||
posterUrl: tmdbPosterUrl(result.poster_path),
|
||||
rating,
|
||||
popularity: result.popularity ?? 0,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.tmdbId > 0 && entry.title !== '');
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,22 @@ export interface TmdbSearchResult {
|
||||
first_air_date?: string;
|
||||
popularity?: number;
|
||||
vote_count?: number;
|
||||
vote_average?: number;
|
||||
poster_path?: string | null;
|
||||
}
|
||||
|
||||
/** One dashboard-ready trending title (movie or series) */
|
||||
export interface TmdbTrendingEntry {
|
||||
tmdbId: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
title: string;
|
||||
year: number | null;
|
||||
posterUrl: string | null;
|
||||
/** vote_average rounded to one decimal, null without votes */
|
||||
rating: string | null;
|
||||
popularity: number;
|
||||
}
|
||||
|
||||
export interface TmdbSearchResponse {
|
||||
results?: TmdbSearchResult[];
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface DashboardRailsSettings {
|
||||
favoriteMoviesAndSeries: boolean;
|
||||
recentSources: boolean;
|
||||
xtreamRecentlyAdded: boolean;
|
||||
/** TMDB "Trending this week" rail (needs the TMDB opt-in; Electron) */
|
||||
tmdbTrending: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_DASHBOARD_RAILS_SETTINGS: DashboardRailsSettings = {
|
||||
@@ -43,6 +45,7 @@ export const DEFAULT_DASHBOARD_RAILS_SETTINGS: DashboardRailsSettings = {
|
||||
favoriteMoviesAndSeries: true,
|
||||
recentSources: true,
|
||||
xtreamRecentlyAdded: true,
|
||||
tmdbTrending: true,
|
||||
};
|
||||
|
||||
export type DashboardRailsSettingsInput = Partial<
|
||||
|
||||
@@ -30,6 +30,7 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
|
||||
ratingKinopoisk: info?.rating_kinopoisk,
|
||||
youtubeTrailer: info?.youtube_trailer,
|
||||
tmdbCast: info?.tmdb_cast,
|
||||
tmdbRecommendations: info?.tmdb_recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +59,7 @@ export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta
|
||||
// Stalker portals provide no trailers; TMDB enrichment can
|
||||
youtubeTrailer: info?.tmdb_trailer,
|
||||
tmdbCast: info?.tmdb_cast,
|
||||
tmdbRecommendations: info?.tmdb_recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { XtreamVodDetails } from './xtream-vod-details.interface';
|
||||
import { StalkerVodDetails } from './stalker-vod-details.interface';
|
||||
import { TmdbEnrichedCastMember } from './tmdb.interface';
|
||||
import { TmdbEnrichedCastMember,
|
||||
TmdbRecommendation,
|
||||
} from './tmdb.interface';
|
||||
|
||||
/**
|
||||
* Discriminated union for VOD details across portal types.
|
||||
@@ -82,6 +84,8 @@ export interface NormalizedVodMeta {
|
||||
youtubeTrailer?: string;
|
||||
/** Cast with profile photos, populated by TMDB enrichment */
|
||||
tmdbCast?: TmdbEnrichedCastMember[];
|
||||
/** TMDB recommendations (drives the cross-portal "Similar" rail) */
|
||||
tmdbRecommendations?: TmdbRecommendation[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -190,6 +190,43 @@
|
||||
}
|
||||
|
||||
<div detail-extras>
|
||||
@if (similarInPortals().length) {
|
||||
<section class="details__similar" data-test-id="tmdb-similar">
|
||||
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
|
||||
<div class="details__similar-strip">
|
||||
@for (item of similarInPortals(); track item.match.xtreamId) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__similar-card"
|
||||
[title]="item.title + ' — ' + item.match.playlistName"
|
||||
(click)="openSimilarInPortals(item)"
|
||||
>
|
||||
@if (item.posterUrl) {
|
||||
<img
|
||||
class="details__similar-poster"
|
||||
[src]="item.posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__similar-poster details__similar-poster--fallback"
|
||||
>
|
||||
{{ item.title.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__similar-title">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="details__similar-source">
|
||||
{{ item.match.playlistName }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- YouTube Trailer (provider or TMDB enrichment) -->
|
||||
@if (trailerEmbedUrl(); as trailerUrl) {
|
||||
<div class="youtube-trailer">
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { Component, computed, effect, inject, input, output, signal, untracked } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { SafePipe } from '@iptvnator/pipes';
|
||||
@@ -25,7 +19,11 @@ import {
|
||||
normalizeVodDetails,
|
||||
youtubeEmbedUrl,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import {
|
||||
CrossPortalSimilarItem,
|
||||
CrossPortalSimilarService,
|
||||
DownloadsService,
|
||||
} from '@iptvnator/services';
|
||||
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
|
||||
|
||||
@@ -117,6 +115,7 @@ export class VodDetailsComponent {
|
||||
// ============ Services ============
|
||||
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
|
||||
private readonly externalPlaybackActions = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@@ -134,6 +133,41 @@ export class VodDetailsComponent {
|
||||
youtubeEmbedUrl(this.normalizedMeta().youtubeTrailer)
|
||||
);
|
||||
|
||||
/**
|
||||
* TMDB recommendations found in the user's OTHER portals (batched DB
|
||||
* match, Electron only). Loaded async — the section appears when
|
||||
* resolved; staleness-guarded against item changes in flight.
|
||||
*/
|
||||
readonly similarInPortals = signal<CrossPortalSimilarItem[]>([]);
|
||||
|
||||
private readonly loadSimilarInPortals = effect(() => {
|
||||
const meta = this.normalizedMeta();
|
||||
const recommendations = meta.tmdbRecommendations;
|
||||
untracked(() => {
|
||||
this.similarInPortals.set([]);
|
||||
if (
|
||||
!recommendations?.length ||
|
||||
!this.crossPortalSimilar.isAvailable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void this.crossPortalSimilar
|
||||
.matchRecommendations(recommendations, 'movie')
|
||||
.then((items) => {
|
||||
if (
|
||||
this.normalizedMeta().tmdbRecommendations ===
|
||||
recommendations
|
||||
) {
|
||||
this.similarInPortals.set(items);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
openSimilarInPortals(item: CrossPortalSimilarItem): void {
|
||||
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
|
||||
}
|
||||
|
||||
/** Whether there's a playback position to resume from */
|
||||
readonly hasPlaybackPosition = computed(() => {
|
||||
const pos = this.playbackPosition();
|
||||
|
||||
@@ -269,6 +269,15 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Source playlist badge on cross-portal similar cards
|
||||
&__similar-source {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.6875rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './lib/dashboard-data.service';
|
||||
export * from './lib/dashboard-trending.service';
|
||||
@@ -0,0 +1,130 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
CatalogTitleMatchService,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
import type { TmdbTrendingEntry } from '@iptvnator/services';
|
||||
import { CatalogTitleMatch } from '@iptvnator/shared/interfaces';
|
||||
import { DashboardTrendingService } from './dashboard-trending.service';
|
||||
|
||||
describe('DashboardTrendingService', () => {
|
||||
const entry = (
|
||||
overrides: Partial<TmdbTrendingEntry> = {}
|
||||
): TmdbTrendingEntry => ({
|
||||
tmdbId: 603,
|
||||
mediaType: 'movie',
|
||||
title: 'The Matrix',
|
||||
year: 1999,
|
||||
posterUrl: null,
|
||||
rating: '8.2',
|
||||
popularity: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const match = (
|
||||
overrides: Partial<CatalogTitleMatch> = {}
|
||||
): CatalogTitleMatch => ({
|
||||
queryTitle: 'The Matrix',
|
||||
playlistId: 'pl-1',
|
||||
playlistName: 'My Portal',
|
||||
categoryId: 7,
|
||||
xtreamId: 42,
|
||||
type: 'movie',
|
||||
trailingYear: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let getTrendingWeek: jest.Mock;
|
||||
let matchTitles: jest.Mock;
|
||||
let isEnabled: jest.Mock;
|
||||
|
||||
function createService(
|
||||
options: { matchingAvailable?: boolean } = {}
|
||||
): DashboardTrendingService {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: { isEnabled, getTrendingWeek },
|
||||
},
|
||||
{
|
||||
provide: CatalogTitleMatchService,
|
||||
useValue: {
|
||||
isAvailable: options.matchingAvailable ?? true,
|
||||
matchTitles,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return TestBed.inject(DashboardTrendingService);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isEnabled = jest.fn().mockReturnValue(true);
|
||||
getTrendingWeek = jest.fn().mockResolvedValue([entry()]);
|
||||
matchTitles = jest.fn().mockResolvedValue([match()]);
|
||||
});
|
||||
|
||||
it('does nothing when TMDB is disabled', async () => {
|
||||
isEnabled.mockReturnValue(false);
|
||||
const service = createService();
|
||||
|
||||
await service.load();
|
||||
|
||||
expect(getTrendingWeek).not.toHaveBeenCalled();
|
||||
expect(service.items()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does nothing without the Electron title matcher (PWA)', async () => {
|
||||
const service = createService({ matchingAvailable: false });
|
||||
|
||||
await service.load();
|
||||
|
||||
expect(getTrendingWeek).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('attaches library matches to trending entries', async () => {
|
||||
const service = createService();
|
||||
|
||||
await service.load();
|
||||
|
||||
expect(service.items()).toHaveLength(1);
|
||||
expect(service.items()[0].match?.playlistName).toBe('My Portal');
|
||||
expect(service.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects year-incompatible base-tier matches', async () => {
|
||||
getTrendingWeek.mockResolvedValue([
|
||||
entry({ title: 'Blade Runner', year: 1982 }),
|
||||
]);
|
||||
matchTitles.mockResolvedValue([
|
||||
match({ queryTitle: 'Blade Runner', trailingYear: 2049 }),
|
||||
]);
|
||||
const service = createService();
|
||||
|
||||
await service.load();
|
||||
|
||||
expect(service.items()[0].match).toBeNull();
|
||||
});
|
||||
|
||||
it('loads only once per session after a successful load', async () => {
|
||||
const service = createService();
|
||||
|
||||
await service.load();
|
||||
await service.load();
|
||||
|
||||
expect(getTrendingWeek).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries on the next visit when the first load came back empty', async () => {
|
||||
getTrendingWeek.mockResolvedValueOnce([]);
|
||||
const service = createService();
|
||||
|
||||
await service.load();
|
||||
expect(service.items()).toEqual([]);
|
||||
|
||||
await service.load();
|
||||
expect(getTrendingWeek).toHaveBeenCalledTimes(2);
|
||||
expect(service.items()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import {
|
||||
CatalogTitleMatchService,
|
||||
TmdbEnrichmentService,
|
||||
buildTitleMatchIndex,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
CatalogTitleMatch,
|
||||
normalizeTitleKeys,
|
||||
titleYearsCompatible,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { TmdbTrendingEntry } from '@iptvnator/services';
|
||||
|
||||
/** One trending card: TMDB entry + optional library match for navigation */
|
||||
export interface DashboardTrendingItem extends TmdbTrendingEntry {
|
||||
/** Confident match in an imported Xtream playlist, when found */
|
||||
match: CatalogTitleMatch | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Trending this week" dashboard rail data. Requires the TMDB opt-in and
|
||||
* (for availability matching / navigation) the Electron DB worker — the
|
||||
* rail is hidden in the PWA. Trending lists are cached for a day, and the
|
||||
* title match runs as ONE batched worker request, fired only after the
|
||||
* dashboard's own data has loaded so it never competes for the worker.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardTrendingService {
|
||||
private readonly enrichment = inject(TmdbEnrichmentService);
|
||||
private readonly titleMatch = inject(CatalogTitleMatchService);
|
||||
|
||||
readonly items = signal<DashboardTrendingItem[]>([]);
|
||||
readonly loading = signal(false);
|
||||
|
||||
private loadedOnce = false;
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return this.enrichment.isEnabled() && this.titleMatch.isAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs once per app session on success. Empty or failed loads (TMDB
|
||||
* temporarily unreachable) do NOT latch, so the next dashboard visit
|
||||
* retries instead of hiding the rail until an app restart.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
if (this.loadedOnce || this.loading() || !this.isAvailable) {
|
||||
return;
|
||||
}
|
||||
this.loading.set(true);
|
||||
|
||||
try {
|
||||
const entries = await this.enrichment.getTrendingWeek();
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = await this.titleMatch.matchTitles(
|
||||
entries.map((entry) => entry.title)
|
||||
);
|
||||
const index = buildTitleMatchIndex(matches);
|
||||
|
||||
this.items.set(
|
||||
entries.map((entry) => ({
|
||||
...entry,
|
||||
match: this.matchFor(entry, index),
|
||||
}))
|
||||
);
|
||||
this.loadedOnce = true;
|
||||
} catch (error) {
|
||||
console.warn('Dashboard trending load failed:', error);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private matchFor(
|
||||
entry: TmdbTrendingEntry,
|
||||
index: ReadonlyMap<string, CatalogTitleMatch>
|
||||
): CatalogTitleMatch | null {
|
||||
const type = entry.mediaType === 'movie' ? 'movie' : 'series';
|
||||
const key = `${type}:${normalizeTitleKeys(entry.title).exact}`;
|
||||
const match = index.get(key) ?? null;
|
||||
return match && titleYearsCompatible(entry.year, match.trailingYear)
|
||||
? match
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TmdbEnrichmentService } from '@iptvnator/services';
|
||||
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
|
||||
|
||||
describe('DashboardHeroTmdbService', () => {
|
||||
const tvDetails = {
|
||||
id: 100,
|
||||
backdrop_path: '/serial.jpg',
|
||||
vote_average: 7.4,
|
||||
vote_count: 12,
|
||||
genres: [{ id: 1, name: 'Drama' }, { id: 2, name: 'Comedy' }],
|
||||
};
|
||||
|
||||
let isEnabled: jest.Mock;
|
||||
let enrichMovie: jest.Mock;
|
||||
let enrichTv: jest.Mock;
|
||||
|
||||
function createService(): DashboardHeroTmdbService {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: { isEnabled, enrichMovie, enrichTv },
|
||||
},
|
||||
],
|
||||
});
|
||||
return TestBed.inject(DashboardHeroTmdbService);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isEnabled = jest.fn().mockReturnValue(true);
|
||||
enrichMovie = jest.fn().mockResolvedValue(null);
|
||||
enrichTv = jest.fn().mockResolvedValue(tvDetails);
|
||||
});
|
||||
|
||||
it('returns null when TMDB is disabled or the item is live', async () => {
|
||||
isEnabled.mockReturnValue(false);
|
||||
const service = createService();
|
||||
await expect(
|
||||
service.getExtras({ title: 'X', type: 'movie' })
|
||||
).resolves.toBeNull();
|
||||
|
||||
isEnabled.mockReturnValue(true);
|
||||
await expect(
|
||||
service.getExtras({ title: 'X', type: 'live' })
|
||||
).resolves.toBeNull();
|
||||
expect(enrichMovie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to a TV lookup for movie-typed items without a movie match', async () => {
|
||||
// Stalker embedded-series items are typed 'movie' in activity rows
|
||||
const service = createService();
|
||||
|
||||
const extras = await service.getExtras({
|
||||
title: 'Между нами химия (8 серий)',
|
||||
type: 'movie',
|
||||
});
|
||||
|
||||
expect(enrichMovie).toHaveBeenCalledTimes(1);
|
||||
expect(enrichTv).toHaveBeenCalledTimes(1);
|
||||
expect(extras?.backdropUrl).toContain('/serial.jpg');
|
||||
expect(extras?.rating).toBe('7.4');
|
||||
expect(extras?.genres).toEqual(['Drama', 'Comedy']);
|
||||
});
|
||||
|
||||
it('does not retry as TV when the movie lookup succeeds', async () => {
|
||||
enrichMovie.mockResolvedValue({ ...tvDetails, id: 200 });
|
||||
const service = createService();
|
||||
|
||||
await service.getExtras({ title: 'Heat', type: 'movie' });
|
||||
|
||||
expect(enrichTv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('memoizes results per title and type', async () => {
|
||||
const service = createService();
|
||||
|
||||
await service.getExtras({ title: 'The Boys', type: 'series' });
|
||||
await service.getExtras({ title: 'The Boys', type: 'series' });
|
||||
|
||||
expect(enrichTv).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
TmdbEnrichmentService,
|
||||
extractYear,
|
||||
tmdbBackdropUrl,
|
||||
} from '@iptvnator/services';
|
||||
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
|
||||
|
||||
/** TMDB extras for the dashboard hero, patched in after first paint */
|
||||
export interface DashboardHeroTmdbExtras {
|
||||
readonly backdropUrl: string | null;
|
||||
readonly rating: string | null;
|
||||
readonly genres: readonly string[];
|
||||
}
|
||||
|
||||
const MAX_HERO_GENRES = 2;
|
||||
|
||||
/**
|
||||
* Best-effort TMDB extras for the hero card (backdrop, rating, genres).
|
||||
* Goes through the enrichment facade, so items already opened in a detail
|
||||
* view resolve from the SQLite cache without network. Results are memoized
|
||||
* per title for the session — dashboard revisits skip the IPC round-trip.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardHeroTmdbService {
|
||||
private readonly enrichment = inject(TmdbEnrichmentService);
|
||||
private readonly memo = new Map<
|
||||
string,
|
||||
Promise<DashboardHeroTmdbExtras | null>
|
||||
>();
|
||||
|
||||
/** Reactive when read inside a computed (settings signal underneath) */
|
||||
isEnabled(): boolean {
|
||||
return this.enrichment.isEnabled();
|
||||
}
|
||||
|
||||
getExtras(
|
||||
item: Pick<GlobalRecentItem, 'title' | 'type'>
|
||||
): Promise<DashboardHeroTmdbExtras | null> {
|
||||
if (
|
||||
!this.enrichment.isEnabled() ||
|
||||
(item.type !== 'movie' && item.type !== 'series')
|
||||
) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const key = `${item.type}:${item.title}`;
|
||||
const cached = this.memo.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const pending = this.loadExtras(item);
|
||||
this.memo.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async loadExtras(
|
||||
item: Pick<GlobalRecentItem, 'title' | 'type'>
|
||||
): Promise<DashboardHeroTmdbExtras | null> {
|
||||
try {
|
||||
const query = {
|
||||
title: item.title,
|
||||
year: extractYear(null, item.title),
|
||||
};
|
||||
let details =
|
||||
item.type === 'movie'
|
||||
? await this.enrichment.enrichMovie(query)
|
||||
: await this.enrichment.enrichTv(query);
|
||||
if (!details && item.type === 'movie') {
|
||||
// Stalker embedded-series items (vclub) are typed 'movie'
|
||||
// in activity rows but are TV shows on TMDB — the detail
|
||||
// view resolves them via is_series, so the TV lookup is
|
||||
// usually already cached. Misses are negative-cached, so
|
||||
// the retry never turns into repeated network traffic.
|
||||
details = await this.enrichment.enrichTv(query);
|
||||
}
|
||||
if (!details) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rating =
|
||||
(details.vote_count ?? 0) > 0 && details.vote_average
|
||||
? details.vote_average.toFixed(1)
|
||||
: null;
|
||||
const genres = (details.genres ?? [])
|
||||
.map((genre) => genre.name)
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_HERO_GENRES);
|
||||
|
||||
return {
|
||||
backdropUrl: tmdbBackdropUrl(details.backdrop_path),
|
||||
rating,
|
||||
genres,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Dashboard hero TMDB extras failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@ export interface DashboardHeroModel {
|
||||
readonly state?: Record<string, unknown>;
|
||||
readonly subtitle: string;
|
||||
readonly title: string;
|
||||
/** TMDB vote average ("8.1"), patched in async when available */
|
||||
readonly rating?: string | null;
|
||||
/** Up to two TMDB genre names, patched in async when available */
|
||||
readonly genres?: readonly string[];
|
||||
/** "S2·E5" for series with a tracked episode position */
|
||||
readonly episodeBadge?: string | null;
|
||||
/** 0-100 watched, when a resume position is known. */
|
||||
readonly watchProgress?: number | null;
|
||||
readonly remainingLabel?: DashboardRemainingLabel | null;
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<a
|
||||
class="rail__channel-link"
|
||||
[routerLink]="card.link"
|
||||
[queryParams]="card.queryParams ?? null"
|
||||
[state]="card.state"
|
||||
>
|
||||
<div class="rail__channel-row">
|
||||
@@ -141,6 +142,7 @@
|
||||
<a
|
||||
class="rail__card-link"
|
||||
[routerLink]="card.link"
|
||||
[queryParams]="card.queryParams ?? null"
|
||||
[state]="card.state"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface DashboardRailCard {
|
||||
icon: string;
|
||||
contentType?: 'live' | 'movie' | 'series';
|
||||
link: string[];
|
||||
queryParams?: Record<string, string>;
|
||||
state?: Record<string, unknown>;
|
||||
actions?: DashboardRailAction[];
|
||||
epgLookupKey?: string;
|
||||
|
||||
+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;
|
||||
|
||||
+118
-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,17 @@ export class WorkspaceDashboardRailsComponent {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Single reactive read, gated on the TMDB opt-in so cached
|
||||
// extras vanish immediately when the user opts out mid-session
|
||||
const extrasState = this.heroTmdbExtras();
|
||||
const extras =
|
||||
this.heroTmdb.isEnabled() &&
|
||||
extrasState?.key === this.heroTmdbKey(item)
|
||||
? extrasState.extras
|
||||
: null;
|
||||
const artwork = resolveDashboardHeroArtwork(
|
||||
{
|
||||
backdropUrl: item.backdrop_url,
|
||||
backdropUrl: item.backdrop_url || (extras?.backdropUrl ?? null),
|
||||
posterUrl: item.poster_url,
|
||||
title: item.title,
|
||||
},
|
||||
@@ -158,6 +178,18 @@ export class WorkspaceDashboardRailsComponent {
|
||||
item.type === 'live'
|
||||
? this.getLiveEpgDetailsForCard(this.heroLiveCard())
|
||||
: null;
|
||||
const episodeBadge =
|
||||
item.type === 'series' &&
|
||||
position?.seasonNumber != null &&
|
||||
position?.episodeNumber != null
|
||||
? this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
|
||||
{
|
||||
season: position.seasonNumber,
|
||||
episode: position.episodeNumber,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...artwork,
|
||||
@@ -167,8 +199,13 @@ export class WorkspaceDashboardRailsComponent {
|
||||
state: this.data.getRecentItemNavigationState(item),
|
||||
subtitle: this.buildHeroSubtitle(item),
|
||||
title: item.title,
|
||||
watchProgress: playbackProgressPercent(position),
|
||||
remainingLabel: formatRemainingLabel(position),
|
||||
rating: extras?.rating ?? null,
|
||||
genres: extras?.genres ?? [],
|
||||
episodeBadge,
|
||||
watchProgress:
|
||||
item.type === 'live' ? null : playbackProgressPercent(position),
|
||||
remainingLabel:
|
||||
item.type === 'live' ? null : formatRemainingLabel(position),
|
||||
nowPlayingTitle: liveEpgDetails?.nowPlayingTitle ?? null,
|
||||
nowPlayingTimeRange: liveEpgDetails?.nowPlayingTimeRange ?? null,
|
||||
nowPlayingProgress: liveEpgDetails?.nowPlayingProgress ?? null,
|
||||
@@ -289,6 +326,18 @@ export class WorkspaceDashboardRailsComponent {
|
||||
.map((item) => this.toRecentlyAddedCard(item))
|
||||
);
|
||||
|
||||
readonly trendingCards = computed<DashboardRailCard[]>(() => {
|
||||
this.languageTick();
|
||||
// isAvailable reads the TMDB settings signal — flipping the
|
||||
// opt-in off mid-session hides the rail immediately
|
||||
if (!this.trendingService.isAvailable) {
|
||||
return [];
|
||||
}
|
||||
return this.trendingService
|
||||
.items()
|
||||
.map((item) => this.toTrendingCard(item));
|
||||
});
|
||||
|
||||
readonly sourceCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data.recentPlaylists().map((playlist) => ({
|
||||
id: playlist._id,
|
||||
@@ -338,6 +387,40 @@ export class WorkspaceDashboardRailsComponent {
|
||||
this.playbackPositionReloadKey();
|
||||
untracked(() => void this.data.reloadPlaybackPositions());
|
||||
});
|
||||
|
||||
// TMDB extras for the hero (backdrop/rating/genres) — async after
|
||||
// first paint, staleness-guarded against hero changes in flight.
|
||||
effect(() => {
|
||||
const item = this.heroRecentItem();
|
||||
if (!item || (item.type !== 'movie' && item.type !== 'series')) {
|
||||
return;
|
||||
}
|
||||
const key = this.heroTmdbKey(item);
|
||||
untracked(() => {
|
||||
if (this.heroTmdbExtras()?.key === key) {
|
||||
return;
|
||||
}
|
||||
void this.heroTmdb.getExtras(item).then((extras) => {
|
||||
const current = untracked(() => this.heroRecentItem());
|
||||
if (current && this.heroTmdbKey(current) === key) {
|
||||
this.heroTmdbExtras.set({ key, extras });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Trending rail: needs the TMDB opt-in and the Electron DB worker.
|
||||
// Deferred until the dashboard's own recent/favorites data is in so
|
||||
// the batched title match never competes for the worker at startup.
|
||||
effect(() => {
|
||||
if (
|
||||
!this.dashboardRails().tmdbTrending ||
|
||||
!this.data.globalFavoritesLoaded()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
untracked(() => void this.trendingService.load());
|
||||
});
|
||||
}
|
||||
|
||||
onAddPlaylist(type?: WorkspacePlaylistType): void {
|
||||
@@ -481,6 +564,38 @@ export class WorkspaceDashboardRailsComponent {
|
||||
};
|
||||
}
|
||||
|
||||
private heroTmdbKey(item: GlobalRecentItem): string {
|
||||
return `${item.type}:${item.title}`;
|
||||
}
|
||||
|
||||
private toTrendingCard(item: DashboardTrendingItem): DashboardRailCard {
|
||||
const subtitle = [
|
||||
item.year !== null ? String(item.year) : null,
|
||||
item.rating ? `★ ${item.rating}` : null,
|
||||
item.match?.playlistName ?? null,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' · ');
|
||||
return {
|
||||
id: `trending-${item.mediaType}-${item.tmdbId}`,
|
||||
title: item.title,
|
||||
subtitle,
|
||||
imageUrl: item.posterUrl ?? undefined,
|
||||
icon: item.mediaType === 'movie' ? 'movie' : 'video_library',
|
||||
contentType: item.mediaType === 'movie' ? 'movie' : 'series',
|
||||
link: item.match
|
||||
? [
|
||||
'/workspace/xtreams',
|
||||
item.match.playlistId,
|
||||
item.match.type === 'movie' ? 'vod' : 'series',
|
||||
String(item.match.categoryId),
|
||||
String(item.match.xtreamId),
|
||||
]
|
||||
: ['/workspace/search'],
|
||||
...(item.match ? {} : { queryParams: { q: item.title } }),
|
||||
};
|
||||
}
|
||||
|
||||
private typeIcon(type: 'live' | 'movie' | 'series'): string {
|
||||
if (type === 'live') return 'live_tv';
|
||||
if (type === 'movie') return 'movie';
|
||||
|
||||
Reference in new issue
Block a user