diff --git a/.changes/dashboard-tmdb-recommendations.md b/.changes/dashboard-tmdb-recommendations.md new file mode 100644 index 000000000..cc8bc69a4 --- /dev/null +++ b/.changes/dashboard-tmdb-recommendations.md @@ -0,0 +1,10 @@ +--- +type: feature +area: dashboard +--- + +The dashboard gains a "Because you watched" rail: TMDB recommendations seeded +from your recently watched movies and series, showing only titles that actually +exist in your imported libraries. It appears once at least five matches are +found, can be turned off under Settings → Dashboard, and requires the TMDB +metadata opt-in (desktop app only). diff --git a/CLAUDE.md b/CLAUDE.md index caf3e1f81..6d96f2fa6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1258,7 +1258,7 @@ engine` (restart required) or - 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 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`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`) -- 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 lookup identity; 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. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; a `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url` +- 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), a "Because you watched" recommendations rail (`dashboardRails.tmdbRecommendations` toggle; TMDB has no account-free "for you" endpoint, so `DashboardRecommendationsService` seeds per-title `recommendations` — already riding in every cached details payload — from up to 3 recently watched movies/series via the shared `dashboard-tmdb-lookup.util.ts` attempt builder, interleaves them, dedupes by TMDB id (title collisions are resolved after matching, by the catalog row, so same-titled remakes both reach the matcher), drops watched/favorited titles through a year-gated exclusion index built by the same lookup-attempt builder (so a Stalker embedded-VOD series indexes under `series:` despite routing as `movie`, and its stored `o_name` alias counts too; only the PRIMARY attempt is indexed, or a watched film would swallow the same-named show) on two title tiers (exact normalized title plus a year-gated base tier so a stored "Inception 2010" excludes TMDB's "Inception" while "Blade Runner 2049" does not swallow the 1982 film), keeps only year-compatible `DB_MATCH_TITLES` matches — matching/exclusion run through both the localized title and the TMDB original-title alias, and a year-incompatible first alias falls through to the other — and hides the rail below 5 cards while resetting the latch; successful loads are keyed by TMDB language + seed set + watched/favorited exclusion set + imported-playlist ids, an emptied history clears the rail, a mid-flight load request is queued, and a no-seed-resolved load retries instead of latching) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; 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. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; an unconfirmed Stalker `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`; a confirmed `movie` gets none either, and is confirmed by an Xtream `source` (that catalog files movies and series apart) or by a stored Stalker `info.tmdb_id` (never a provider claim, only a match this app already gated). The lookup key is the WHOLE attempt sequence, since two rows can share title/year/id yet differ in whether a `tv` fallback follows, and callers memoize by it. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url` - Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday` - Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; 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` diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 3e1d623de..f05e3192e 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -597,6 +597,8 @@ test.describe('Electron Settings', () => { 'toggle-dashboard-rail-favorite-movies-and-series', 'toggle-dashboard-rail-recent-sources', 'toggle-dashboard-rail-xtream-recently-added', + 'toggle-dashboard-rail-tmdb-trending', + 'toggle-dashboard-rail-tmdb-recommendations', ]) { await expect( firstLaunch.mainWindow @@ -675,6 +677,42 @@ test.describe('Electron Settings', () => { } }); + // The TMDB recommendations rail itself needs the TMDB opt-in, live + // TMDB data and catalog matches, so its rendering is covered by unit + // tests rather than here. What IS deterministic — and what a form + // regression would silently break — is that its toggle persists. + test('@settings @dashboard @electron persists the TMDB recommendations rail toggle across a restart', async ({ + dataDir, + }) => { + let app = await launchElectronApp(dataDir); + + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'dashboard'); + + const toggle = app.mainWindow + .getByTestId('toggle-dashboard-rail-tmdb-recommendations') + .locator('input[type="checkbox"]'); + await expect(toggle).toBeChecked(); + await toggle.uncheck(); + await saveSettings(app.mainWindow); + } finally { + app = await restartElectronApp(app, dataDir); + } + + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'dashboard'); + await expect( + app.mainWindow + .getByTestId('toggle-dashboard-rail-tmdb-recommendations') + .locator('input[type="checkbox"]') + ).not.toBeChecked(); + } finally { + await closeElectronApp(app); + } + }); + test('restores the last section-level view across restart when configured', async ({ dataDir, }) => { diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.html b/apps/web/src/app/settings/settings-dashboard-section.component.html index 1b35089af..5c5657c4f 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.html +++ b/apps/web/src/app/settings/settings-dashboard-section.component.html @@ -177,5 +177,28 @@ > + +
+
+

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

+

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

+
+
+ +
+
diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 5f2c99915..3632e6c0c 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -55,6 +55,8 @@ export function createSettingsForm( xtreamRecentlyAdded: DEFAULT_DASHBOARD_RAILS_SETTINGS.xtreamRecentlyAdded, tmdbTrending: DEFAULT_DASHBOARD_RAILS_SETTINGS.tmdbTrending, + tmdbRecommendations: + DEFAULT_DASHBOARD_RAILS_SETTINGS.tmdbRecommendations, }), startupBehavior: StartupBehavior.FirstView, showExternalPlaybackBar: true, diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index a5b89d662..a755d261c 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -79,6 +79,7 @@ export const DEFAULT_DASHBOARD_RAILS = { recentSources: true, xtreamRecentlyAdded: true, tmdbTrending: true, + tmdbRecommendations: true, }; export const DEFAULT_SETTINGS = { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index ce6b49cbb..889665632 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "مقترحات لك (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "عرض اقتراحات بناءً على الأفلام والمسلسلات التي شاهدتها مؤخراً مع مطابقتها بمكتباتك. يتطلب تفعيل بيانات 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "ما تمت مشاهدته مؤخرًا", "XTREAM_RECENTLY_ADDED": "أُضيفت مؤخرًا على Xtream", "TMDB_TRENDING": "الرائج هذا الأسبوع", + "TMDB_RECOMMENDED": "مقترحات لك", + "TMDB_RECOMMENDED_BECAUSE": "لأنك شاهدت {{title}}", "NO_HISTORY": "لم يتم العثور على عناصر لهذا الفلتر.", "NO_HISTORY_HINT": "سيظهر سجل المشاهدة هنا بمجرد بدء تشغيل محتوى من قوائم التشغيل الخاصة بك.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 2e5cc5a32..fd78da686 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "مقترحات ليك (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "عرض مقترحات على حساب الأفلام والمسلسلات اللي تفرجتي فيهم مؤخراً، مطابقين مع المكتبات ديالك. خاص تفعيل 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "متشافين مؤخراً", "XTREAM_RECENTLY_ADDED": "تزادو مؤخراً على Xtream", "TMDB_TRENDING": "الرائج هاد السيمانة", + "TMDB_RECOMMENDED": "مقترحات ليك", + "TMDB_RECOMMENDED_BECAUSE": "حيتاش تفرجتي ف {{title}}", "NO_HISTORY": "ما تلقاو حتى عناصر لهاد الفلتر.", "NO_HISTORY_HINT": "سجل المشاهدات ديالك غادي يبان هنا منين تبدا تشغل محتوى من قوائم التشغيل ديالك.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7f2cc8fd1..39d59a8dc 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "Рэкамендацыі для вас (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Паказваць рэкамендацыі на аснове нядаўна прагледжаных фільмаў і серыялаў, супастаўленыя з вашымі бібліятэкамі. Патрабуе ўключаных метаданых 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Нядаўна прагледжаныя", "XTREAM_RECENTLY_ADDED": "Нядаўна дададзеныя на Xtream", "TMDB_TRENDING": "У трэндзе на гэтым тыдні", + "TMDB_RECOMMENDED": "Рэкамендацыі для вас", + "TMDB_RECOMMENDED_BECAUSE": "Таму што вы глядзелі {{title}}", "NO_HISTORY": "Не знойдзена элементаў для гэтага фільтра.", "NO_HISTORY_HINT": "Гісторыя прагляду з'явіцца тут, як толькі вы пачнеце прайграваць кантэнт з вашых плэйлістоў.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index eb249ca1b..62e78dada 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "Empfehlungen für Sie (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Empfehlungen auf Basis Ihrer zuletzt angesehenen Filme und Serien 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Zuletzt angesehen", "XTREAM_RECENTLY_ADDED": "Neu auf Xtream", "TMDB_TRENDING": "Diese Woche im Trend", + "TMDB_RECOMMENDED": "Empfehlungen für Sie", + "TMDB_RECOMMENDED_BECAUSE": "Weil Sie {{title}} angesehen haben", "NO_HISTORY": "Keine Elemente für diesen Filter gefunden.", "NO_HISTORY_HINT": "Dein Wiedergabeverlauf erscheint hier, sobald du Inhalte aus deinen Playlists abspielst.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a8e7327a3..d4c85f84e 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "Προτάσεις για εσάς (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Εμφάνιση προτάσεων με βάση τις ταινίες και τις σειρές που παρακολουθήσατε πρόσφατα, αντιστοιχισμένων με τις βιβλιοθήκες σας. Απαιτεί ενεργά μεταδεδομένα 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Πρόσφατα προβληθέντα", "XTREAM_RECENTLY_ADDED": "Πρόσφατα προστέθηκαν στο Xtream", "TMDB_TRENDING": "Δημοφιλή αυτής της εβδομάδας", + "TMDB_RECOMMENDED": "Προτάσεις για εσάς", + "TMDB_RECOMMENDED_BECAUSE": "Επειδή παρακολουθήσατε {{title}}", "NO_HISTORY": "Δεν βρέθηκαν στοιχεία για αυτό το φίλτρο.", "NO_HISTORY_HINT": "Το ιστορικό προβολών σας θα εμφανιστεί εδώ μόλις ξεκινήσετε να αναπαράγετε περιεχόμενο από τις λίστες αναπαραγωγής σας.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 3695ede59..253449105 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "Recommended for you (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Show recommendations based on your recently watched movies and series, 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Recently Watched", "XTREAM_RECENTLY_ADDED": "Recently Added on Xtream", "TMDB_TRENDING": "Trending this week", + "TMDB_RECOMMENDED": "Recommended for you", + "TMDB_RECOMMENDED_BECAUSE": "Because you watched {{title}}", "NO_HISTORY": "No items found for this filter.", "NO_HISTORY_HINT": "Your watching history will appear here once you start playing content from your playlists.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 207523bf6..010cb3106 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "Recomendaciones para ti (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Mostrar recomendaciones basadas en las películas y series que has visto recientemente, 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Vistos recientemente", "XTREAM_RECENTLY_ADDED": "Añadidos recientemente en Xtream", "TMDB_TRENDING": "Tendencias de la semana", + "TMDB_RECOMMENDED": "Recomendaciones para ti", + "TMDB_RECOMMENDED_BECAUSE": "Porque viste {{title}}", "NO_HISTORY": "No se encontraron elementos para este filtro.", "NO_HISTORY_HINT": "Tu historial de visualización aparecerá aquí cuando empieces a reproducir contenido de tus listas.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 478033a26..c2f2daadd 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "Recommandations pour vous (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Afficher des recommandations basées sur les films et séries récemment regardés, mises 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Récemment regardés", "XTREAM_RECENTLY_ADDED": "Récemment ajoutés sur Xtream", "TMDB_TRENDING": "Tendances de la semaine", + "TMDB_RECOMMENDED": "Recommandations pour vous", + "TMDB_RECOMMENDED_BECAUSE": "Parce que vous avez regardé {{title}}", "NO_HISTORY": "Aucun élément trouvé pour ce filtre.", "NO_HISTORY_HINT": "Votre historique de visionnage apparaîtra ici une fois que vous aurez commencé à lire du contenu depuis vos listes de lecture.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 52fa198b2..6e798194b 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -455,6 +455,8 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Parancsikonok megjelenítése a legutóbb használt lejátszási listákhoz és portálokhoz.", "DASHBOARD_RAIL_TMDB_TRENDING": "A hét felkapott tartalmai (TMDB)", "DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "A TMDB heti felkapott filmjeinek és sorozatainak megjelenítése, a médiatáraiban elérhető tartalmakhoz párosítva. Használatához engedélyezni kell a TMDB-metaadatokat.", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS": "Ajánlások Önnek (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Ajánlások megjelenítése a legutóbb megtekintett filmek és sorozatok alapján, a médiatáraiban elérhető tartalmakhoz párosítva. Használatához engedélyezni kell a TMDB-metaadatokat.", "DASHBOARD_RAIL_XTREAM_ADDED": "Legutóbb hozzáadott Xtream-tartalmak", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "A legújabb Xtream-filmek, -sorozatok és -csatornák megjelenítése.", "APP_UPDATE_TITLE": "Alkalmazásfrissítés", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Legutóbb megtekintett tartalmak", "XTREAM_RECENTLY_ADDED": "Legutóbb hozzáadott Xtream-tartalmak", "TMDB_TRENDING": "A hét felkapott tartalmai", + "TMDB_RECOMMENDED": "Ajánlások Önnek", + "TMDB_RECOMMENDED_BECAUSE": "Mivel megtekintette: {{title}}", "NO_HISTORY": "Ehhez a szűrőhöz nem található elem.", "NO_HISTORY_HINT": "Megtekintési előzményei itt jelennek meg, amint lejátszik valamit valamelyik lejátszási listáról.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 76fd6f92c..f2b6818db 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "Consigliati per te (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Mostra consigli basati su film e serie visti di recente, 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Visti di recente", "XTREAM_RECENTLY_ADDED": "Aggiunti di recente su Xtream", "TMDB_TRENDING": "Tendenze della settimana", + "TMDB_RECOMMENDED": "Consigliati per te", + "TMDB_RECOMMENDED_BECAUSE": "Perché hai visto {{title}}", "NO_HISTORY": "Nessun elemento corrisponde a questo filtro.", "NO_HISTORY_HINT": "La cronologia di visualizzazione apparirà qui non appena inizi a riprodurre contenuti dalle tue playlist.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index fc8013ccc..7428894db 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "あなたへのおすすめ(TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "最近視聴した映画やシリーズに基づくおすすめをライブラリと照合して表示します。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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "最近視聴した項目", "XTREAM_RECENTLY_ADDED": "Xtreamに最近追加された項目", "TMDB_TRENDING": "今週のトレンド", + "TMDB_RECOMMENDED": "あなたへのおすすめ", + "TMDB_RECOMMENDED_BECAUSE": "「{{title}}」を視聴したあなたへ", "NO_HISTORY": "このフィルターに該当する項目はありません。", "NO_HISTORY_HINT": "プレイリストからコンテンツの再生を開始すると、視聴履歴がここに表示されます。", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 76e5e7cd2..1812618d7 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "맞춤 추천 (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "최근에 시청한 영화와 시리즈를 기반으로 한 추천 콘텐츠를 라이브러리와 대조하여 표시합니다. 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "최근에 시청한 항목", "XTREAM_RECENTLY_ADDED": "Xtream에 최근 추가됨", "TMDB_TRENDING": "이번 주 트렌드", + "TMDB_RECOMMENDED": "맞춤 추천", + "TMDB_RECOMMENDED_BECAUSE": "{{title}} 시청 기반 추천", "NO_HISTORY": "이 필터에 해당하는 항목이 없습니다.", "NO_HISTORY_HINT": "재생목록에서 콘텐츠를 재생하기 시작하면 시청 기록이 여기에 표시됩니다.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index bdde36e4f..4b42f3444 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "Aanbevolen voor u (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Toon aanbevelingen op basis van uw recent bekeken films en series, 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Recent bekeken", "XTREAM_RECENTLY_ADDED": "Recent toegevoegd op Xtream", "TMDB_TRENDING": "Deze week trending", + "TMDB_RECOMMENDED": "Aanbevolen voor u", + "TMDB_RECOMMENDED_BECAUSE": "Omdat u {{title}} heeft bekeken", "NO_HISTORY": "Geen items gevonden voor dit filter.", "NO_HISTORY_HINT": "Je kijkgeschiedenis verschijnt hier zodra je inhoud uit je afspeellijsten begint af te spelen.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 1ed4a1a73..ac76bd2b0 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "Polecane dla Ciebie (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Pokazuj rekomendacje na podstawie ostatnio oglądanych filmów i seriali, 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Ostatnio oglądane", "XTREAM_RECENTLY_ADDED": "Ostatnio dodane w Xtream", "TMDB_TRENDING": "W trendach w tym tygodniu", + "TMDB_RECOMMENDED": "Polecane dla Ciebie", + "TMDB_RECOMMENDED_BECAUSE": "Ponieważ obejrzałeś(-aś) {{title}}", "NO_HISTORY": "Brak elementów dla tego filtra.", "NO_HISTORY_HINT": "Twoja historia oglądania pojawi się tutaj, gdy zaczniesz odtwarzać treści z list odtwarzania.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 8ad9c698c..808ddb1b7 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "Recomendado para si (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Mostrar recomendações com base nos filmes e séries que viu recentemente, correspondidas 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Assistidos recentemente", "XTREAM_RECENTLY_ADDED": "Adicionados recentemente no Xtream", "TMDB_TRENDING": "Tendências da semana", + "TMDB_RECOMMENDED": "Recomendado para si", + "TMDB_RECOMMENDED_BECAUSE": "Porque viu {{title}}", "NO_HISTORY": "Nenhum item encontrado para este filtro.", "NO_HISTORY_HINT": "Seu histórico aparecerá aqui assim que você começar a reproduzir conteúdo das suas playlists.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c32c5af69..6d1c506f1 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -455,6 +455,8 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Показывать быстрый доступ к недавно использованным плейлистам и порталам.", "DASHBOARD_RAIL_TMDB_TRENDING": "В тренде на этой неделе (TMDB)", "DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION": "Показывать фильмы и сериалы недели из TMDB, сопоставленные с вашими библиотеками. Требуются включённые метаданные TMDB.", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS": "Рекомендации для вас (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Показывать рекомендации на основе недавно просмотренных фильмов и сериалов, сопоставленные с вашими библиотеками. Требуются включённые метаданные TMDB.", "DASHBOARD_RAIL_XTREAM_ADDED": "Недавно добавленное на Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Показывать новые фильмы, сериалы и каналы Xtream.", "APP_UPDATE_TITLE": "Обновление приложения", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Недавно просмотренные", "XTREAM_RECENTLY_ADDED": "Недавно добавленные на Xtream", "TMDB_TRENDING": "В тренде на этой неделе", + "TMDB_RECOMMENDED": "Рекомендации для вас", + "TMDB_RECOMMENDED_BECAUSE": "Потому что вы смотрели {{title}}", "NO_HISTORY": "Для этого фильтра элементы не найдены.", "NO_HISTORY_HINT": "История просмотра появится здесь, как только вы начнете воспроизводить контент из своих плейлистов.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 1c2b6ee82..b042eaeeb 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "Size özel öneriler (TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "Son izlediğiniz film ve dizilere göre önerileri 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "Son İzlenenler", "XTREAM_RECENTLY_ADDED": "Xtream'de Yeni Eklenenler", "TMDB_TRENDING": "Bu hafta trend olanlar", + "TMDB_RECOMMENDED": "Size özel öneriler", + "TMDB_RECOMMENDED_BECAUSE": "{{title}} izlediğiniz için", "NO_HISTORY": "Bu filtre için öğe bulunamadı.", "NO_HISTORY_HINT": "Oynatma listelerinizden içerik oynatmaya başladığınızda izleme geçmişiniz burada görünecektir.", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index fa9691c4b..f7428fb46 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "为您推荐(TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "根据您最近观看的电影和剧集显示推荐内容,并与您的媒体库匹配。需要启用 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "最近观看", "XTREAM_RECENTLY_ADDED": "Xtream 最近添加", "TMDB_TRENDING": "本周热门", + "TMDB_RECOMMENDED": "为您推荐", + "TMDB_RECOMMENDED_BECAUSE": "因为您观看了《{{title}}》", "NO_HISTORY": "没有项目匹配此筛选。", "NO_HISTORY_HINT": "开始播放播放列表中的内容后,您的观看历史将显示在此处。", "XTREAM": "Xtream", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 3b290bf9e..5dead155d 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -455,6 +455,8 @@ "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_TMDB_RECOMMENDATIONS": "為您推薦(TMDB)", + "DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION": "根據您最近觀看的電影和影集顯示推薦內容,並與您的媒體庫比對。需要啟用 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", @@ -1272,6 +1274,8 @@ "RECENTLY_WATCHED": "最近觀看", "XTREAM_RECENTLY_ADDED": "Xtream 最近新增", "TMDB_TRENDING": "本週熱門", + "TMDB_RECOMMENDED": "為您推薦", + "TMDB_RECOMMENDED_BECAUSE": "因為您觀看了《{{title}}》", "NO_HISTORY": "找不到符合此篩選條件的項目。", "NO_HISTORY_HINT": "從您的播放清單開始播放內容後,您的觀看記錄就會出現在此處。", "XTREAM": "Xtream", diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 55a128b5f..f8db9ebdc 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -401,9 +401,10 @@ never blocks or delays rendering of the detail view. ## Out of Scope (later phases) -Similar/recommendations rails, actor cross-catalog search, trending -dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache -(IndexedDB). +Artwork upgrade for M3U VOD, persistent PWA cache (IndexedDB). (The +similar rails, actor cross-catalog search, and the trending and +recommendations dashboard rails from earlier versions of this list have +since shipped.) ## Dashboard Integration @@ -418,6 +419,97 @@ dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache (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. +- **Recommendations rail** ("Because you watched", + `dashboardRails.tmdbRecommendations` toggle): TMDB has no account-free + "recommendations for you" endpoint, so `DashboardRecommendationsService` + (`libs/workspace/dashboard/data-access`) seeds from the user's most + recently watched movies/series (up to 3 distinct seeds). Each seed + resolves through the enrichment facade using the shared + `dashboard-tmdb-lookup.util.ts` attempt builder (the same one the hero + uses, including the Stalker hints and the movie→tv retry), and the + `recommendations` list rides along in every cached details payload — + seeds whose detail view was opened cost zero network. Per-seed lists are + interleaved round-robin, deduplicated by TMDB id, stripped of anything + already watched or favorited, and matched against + the imported libraries with ONE batched + `CatalogTitleMatchService.matchTitles` request. The watched/favorited + exclusion index is built through the same lookup-attempt builder the + seeds use, so an activity row is indexed under more than its display + title: under the media type the detail view enriched with (a Stalker + embedded-VOD series routes as `'movie'` but is a `tv` show to TMDB, and + a `series:` recommendation would sail past a `movie:`-only entry) and + under its stored original-language title (`info.o_name`). That resolved + media type REPLACES the routing one rather than joining it — keeping + both would make the watched show exclude an unrelated film of the same + name; a row the builder cannot classify keeps its routing type, the + only thing then known. Only the builder's PRIMARY attempt is indexed — + its second attempt is a fallback guess, and indexing it would let a + watched film exclude the same-named show. On top of that the exclusion runs on two title tiers, because a + provider stores whatever the panel named the file: the exact normalized + title, plus a year-gated base tier for the common `"Inception 2010"` + shape whose exact key can never equal TMDB's `"Inception"`. Both tiers + are year-gated, but from different sources. The exact tier uses only a + year the row STATES in a metadata field (Stalker's `info.releasedate`), + so a watched 1954 `"Godzilla"` cannot exclude the 2014 one — while + `"Blade Runner 2049"`, whose year belongs to the NAME, still excludes + itself. That follows `releaseTagYear`'s rule: on a whole-title match a + trailing number is part of the name and nothing can settle it, so an + inferred year must not veto the match. The base tier keeps using the + trailing year it stripped, which is a year suffix by construction, and + that gate is what keeps a stored `"Blade Runner 2049"` from swallowing + the 1982 `"Blade Runner"`. A row that states no year records `null` and + keeps excluding unconditionally; an unknown year on either side counts + as agreeing, since re-recommending something already watched is the + worse failure. Matching and exclusion + work through BOTH the localized title and the TMDB original-title alias + (cards always display the localized form) — catalogs frequently name + items in their original language while the app language localizes the + TMDB titles; a year-incompatible first-alias match does not veto a + year-compatible match under the other alias. Only matched, + year-compatible titles render (each card navigates to its detail view). + The rail groups the worker's rows per key itself instead of reusing + `buildTitleMatchIndex`, which collapses to one row per key before the + candidate's year is known — with both `"Dune 1984"` and `"Dune 2021"` + in the catalog the wrong one can win that collapse, and the card is + then dropped by the year check with the right row already discarded. + Year-compatible rows from every alias form one pool ranked by + EVIDENCE, not by which alias found them: a row whose stripped year IS + the candidate's wins — positive evidence for that exact film — then an + untagged row (the shared helper's precedence, and the only tier + reachable when the candidate's year is unknown), then anything else + compatible. Alias order survives only as the tiebreaker inside a tier, + so an ambiguous untagged row under the localized title cannot outrank a + year-tagged row the original-title alias found. Title + collisions are resolved AFTER matching, by the catalog row a candidate + resolved to: same-titled remakes ("Dune" 1984 and 2021) are different + films that must both reach the matcher, while two candidates landing on + one row would otherwise render as duplicate cards opening the same + item. Fewer than + `MIN_RECOMMENDATION_MATCHES` (5) hides the rail — and resets + the latch entirely, because an empty match result is indistinguishable + from a transient worker failure (`matchTitles` maps failures to `[]`), + re-running is cheap (mirrors trending's retry-on-empty), and a + previously successful input set must be reloadable after a hidden + interlude. The rail header names the seed ("Because you watched X") + when exactly one seed contributed, else falls back to the generic + "Recommended for you". A load latches only once EVERY seed answered — + a seed that did not resolve may have failed transiently, and latching + on its behalf would drop its recommendations for the session; a seed + that has no TMDB match never resolves either, so that user's rail + re-runs each visit, which is bounded work (cached enrichment misses + plus one batched worker call). Latched loads are keyed by the TMDB language + (payloads are localized; the facade exposes `language()` for this), the + seed set, the watched/favorited exclusion set AND the imported-playlist + id set — watching something new re-seeds on the next dashboard visit, + favoriting a recommended title re-filters it out, a language change + re-localizes the cards, and importing/deleting a playlist re-runs the + matching (a refresh keeps its id and is not detected, parity with + trending); a cleared watch history + clears the rail (the service is root-provided and outlives the + dashboard); a load requested while one is in flight is queued and + re-run afterwards; a load where no seed resolved (TMDB unreachable) + does not latch and retries instead. Same gating as trending: TMDB + opt-in + Electron DB worker, deferred behind the dashboard's own data. - **Hero extras**: `DashboardHeroTmdbService` (`libs/workspace/dashboard/feature`) patches the hero card with a TMDB backdrop (when the activity row has none), a rating badge and up to two diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 79942c6e7..81266e7e8 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -57,6 +57,9 @@ Core implementation: │ Recently Added on Xtream (aggregated across providers) │ │ [poster][poster][poster] →→ │ ├─────────────────────────────────────────────────────────────────────┤ +│ Because you watched X (TMDB, opt-in, Electron-only) │ +│ [poster][poster][poster] →→ │ +├─────────────────────────────────────────────────────────────────────┤ │ Trending this week (TMDB, opt-in, Electron-only) │ │ [poster][poster][poster] →→ │ └─────────────────────────────────────────────────────────────────────┘ @@ -139,7 +142,19 @@ Render rules: first run waits for `globalFavoritesLoaded()` so the slower recently-added DB query does not block the live favorites rail on startup. - 5. `sourceCards` — maps `recentPlaylists()` to rail cards. `recentPlaylists()` + 5. `recommendationCards` / `trendingCards` — the two TMDB rails. Both + need the TMDB opt-in AND the Electron DB worker that answers + `DB_MATCH_TITLES` (each is hidden in the PWA), and both load after + `globalFavoritesLoaded()` so the batched title match never competes + for the worker at startup. `recommendationCards` is seeded from + recently watched movies/series and only shows titles present in an + imported library, hiding itself below five matched cards; its rail + label names the seed ("Because you watched X") when exactly one + seed contributed. `trendingCards` shows TMDB's weekly trending and + falls back to a prefilled global search for unmatched titles. + Contracts: `docs/architecture/tmdb-metadata-enrichment.md` + ("Dashboard Integration"). + 6. `sourceCards` — maps `recentPlaylists()` to rail cards. `recentPlaylists()` ranks M3U, Xtream, and Stalker sources by their latest recent activity from `globalRecentItems()`, then falls back to playlist `updateDate` / `importDate` for sources that have never been used. diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index 80062db2d..ef1015aa4 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -24,6 +24,7 @@ const expectedDashboardRails = ( recentSources: true, xtreamRecentlyAdded: true, tmdbTrending: true, + tmdbRecommendations: true, ...overrides, }); diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts index 600448be5..c39645500 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -53,6 +53,15 @@ export class TmdbEnrichmentService { return this.runtime.isEnabled(); } + /** + * Effective TMDB language ("en-US"). Exposed so consumers that cache + * derived results (e.g. the dashboard recommendations rail) can key + * them by the language the payloads were localized in. + */ + language(): string { + return this.runtime.language(); + } + async enrichMovie( query: TmdbEnrichmentQuery ): Promise { diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 1091391ea..2c1707f48 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -54,6 +54,11 @@ export interface DashboardRailsSettings { xtreamRecentlyAdded: boolean; /** TMDB "Trending this week" rail (needs the TMDB opt-in; Electron) */ tmdbTrending: boolean; + /** + * TMDB "Because you watched" rail seeded from recently watched + * movies/series (needs the TMDB opt-in; Electron) + */ + tmdbRecommendations: boolean; } export const DEFAULT_DASHBOARD_RAILS_SETTINGS: DashboardRailsSettings = { @@ -65,6 +70,7 @@ export const DEFAULT_DASHBOARD_RAILS_SETTINGS: DashboardRailsSettings = { recentSources: true, xtreamRecentlyAdded: true, tmdbTrending: true, + tmdbRecommendations: true, }; export type DashboardRailsSettingsInput = Partial< diff --git a/libs/workspace/dashboard/data-access/src/index.ts b/libs/workspace/dashboard/data-access/src/index.ts index f97ea519d..29b5d17ab 100644 --- a/libs/workspace/dashboard/data-access/src/index.ts +++ b/libs/workspace/dashboard/data-access/src/index.ts @@ -1,4 +1,7 @@ export * from './lib/dashboard-data.service'; +export * from './lib/dashboard-recommendations.service'; +export * from './lib/dashboard-recommendations.util'; export * from './lib/dashboard-source-expiry.service'; export * from './lib/dashboard-source-expiry.util'; +export * from './lib/dashboard-tmdb-lookup.util'; export * from './lib/dashboard-trending.service'; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts new file mode 100644 index 000000000..3de79c66d --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts @@ -0,0 +1,1002 @@ +import { TestBed } from '@angular/core/testing'; +import { + CatalogTitleMatchService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; +import { DashboardDataService } from './dashboard-data.service'; +import { DashboardRecommendationsService } from './dashboard-recommendations.service'; + +interface ActivityStub { + source?: string; + stalker_item?: unknown; + title: string; + type: string; +} + +describe('DashboardRecommendationsService', () => { + const rec = (id: number, title: string, year = 2010) => ({ + id, + title, + release_date: `${year}-01-01`, + vote_average: 7.5, + vote_count: 100, + poster_path: null, + popularity: 50, + }); + + // Distinct catalog rows per title: cards are deduplicated by the row + // they resolve to, so a shared id would collapse the whole rail. + const rowIds = new Map(); + const rowIdFor = (title: string): number => { + const existing = rowIds.get(title); + if (existing !== undefined) { + return existing; + } + const assigned = rowIds.size + 1; + rowIds.set(title, assigned); + return assigned; + }; + + const match = ( + queryTitle: string, + overrides: Partial = {} + ): CatalogTitleMatch => ({ + queryTitle, + playlistId: 'pl-1', + playlistName: 'My Portal', + categoryId: 7, + xtreamId: rowIdFor(queryTitle), + type: 'movie', + trailingYear: null, + ...overrides, + }); + + // Seven recommendations so dropping two (exclusion tests) still leaves + // the rail at MIN_RECOMMENDATION_MATCHES (5). + const recTitles = [ + 'Inception', + 'Interstellar', + 'Tenet', + 'Dunkirk', + 'Memento', + 'Insomnia', + 'The Prestige', + ]; + + let isEnabled: jest.Mock; + let enrichMovie: jest.Mock; + let enrichTv: jest.Mock; + let matchTitles: jest.Mock; + let recentVod: ActivityStub[]; + let recentAll: ActivityStub[]; + let favorites: ActivityStub[]; + let playlists: { _id: string }[]; + let tmdbLanguage: string; + + /** + * Switch the fixture to a series seed, so the candidates it produces + * are `tv`-typed. Exclusion is keyed by media type, so a `movie`-typed + * candidate could never prove anything about the `series:` keys. + */ + function seedTvRecommendations(extraTitles: readonly string[]): void { + recentVod = [{ title: 'The Boys', type: 'series' }]; + recentAll = [...recentVod]; + enrichMovie.mockResolvedValue(null); + enrichTv.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles.slice(0, 5).map((title, i) => rec(100 + i, title)), + ...extraTitles.map((title, i) => rec(200 + i, title)), + ], + }, + }); + // The catalog rows a tv candidate can match are series rows — + // buildTitleMatchIndex keys them by the match's own type. + matchTitles.mockImplementation(async (titles: string[]) => + titles.map((title) => match(title, { type: 'series' })) + ); + } + + function createService( + options: { matchingAvailable?: boolean } = {} + ): DashboardRecommendationsService { + TestBed.configureTestingModule({ + providers: [ + { + provide: TmdbEnrichmentService, + useValue: { + isEnabled, + enrichMovie, + enrichTv, + language: () => tmdbLanguage, + }, + }, + { + provide: CatalogTitleMatchService, + useValue: { + isAvailable: options.matchingAvailable ?? true, + matchTitles, + }, + }, + { + provide: DashboardDataService, + useValue: { + globalRecentVodItems: () => recentVod, + globalRecentItems: () => recentAll, + globalFavoriteItems: () => favorites, + playlists: () => playlists, + }, + }, + ], + }); + return TestBed.inject(DashboardRecommendationsService); + } + + beforeEach(() => { + isEnabled = jest.fn().mockReturnValue(true); + enrichMovie = jest.fn().mockResolvedValue({ + recommendations: { + results: recTitles.map((title, i) => rec(100 + i, title)), + }, + }); + enrichTv = jest.fn().mockResolvedValue(null); + matchTitles = jest + .fn() + .mockImplementation(async (titles: string[]) => + titles.map((title) => match(title)) + ); + recentVod = [{ title: 'The Matrix', type: 'movie' }]; + recentAll = [...recentVod]; + favorites = []; + playlists = [{ _id: 'pl-1' }]; + tmdbLanguage = 'en-US'; + }); + + it('does not retry an Xtream movie seed as a series', async () => { + // The Xtream catalog files movies and series apart, so 'movie' + // is evidence — a same-titled show must not answer for the film. + recentVod = [ + { title: 'Fargo', type: 'movie', source: 'xtream' } as ActivityStub, + ]; + recentAll = [...recentVod]; + enrichMovie.mockResolvedValue(null); + const service = createService(); + + await service.load(); + + expect(enrichTv).not.toHaveBeenCalled(); + }); + + it('does nothing when TMDB is disabled', async () => { + isEnabled.mockReturnValue(false); + const service = createService(); + + await service.load(); + + expect(enrichMovie).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(enrichMovie).not.toHaveBeenCalled(); + }); + + it('builds the rail from matched seed recommendations', async () => { + const service = createService(); + + await service.load(); + + expect(service.items()).toHaveLength(recTitles.length); + expect(service.items()[0].match.playlistName).toBe('My Portal'); + expect(service.seedTitles()).toEqual(['The Matrix']); + expect(service.loading()).toBe(false); + }); + + it('hides the rail below the minimum match count without latching', async () => { + matchTitles.mockImplementationOnce(async (titles: string[]) => + titles.slice(0, 4).map((title) => match(title)) + ); + const service = createService(); + + await service.load(); + expect(service.items()).toEqual([]); + expect(service.seedTitles()).toEqual([]); + + // A transient matcher failure surfaces as an empty result — the + // next visit must retry instead of hiding the rail all session. + await service.load(); + expect(service.items()).toHaveLength(recTitles.length); + }); + + it('excludes titles the user already watched or favorited', async () => { + favorites = [{ title: 'Inception', type: 'movie' }]; + recentAll = [ + ...recentVod, + { title: 'Interstellar', type: 'movie' }, + // A live channel sharing a rec title must NOT exclude the movie + { title: 'Tenet', type: 'live' }, + ]; + const service = createService(); + + await service.load(); + + const titles = service.items().map((item) => item.title); + expect(titles).not.toContain('Inception'); + expect(titles).not.toContain('Interstellar'); + expect(titles).toContain('Tenet'); + }); + + it('excludes a watched Stalker embedded-VOD series from the tv rail', async () => { + // The activity row routes into VOD and is stored as 'movie', + // while TMDB (and the recommendation) knows it as a show — so the + // exclusion must be indexed under the media type the lookup + // builder resolved, not the routing one. + seedTvRecommendations(['Kholod']); + recentAll = [ + ...recentVod, + { + title: 'Kholod', + type: 'movie', + stalker_item: { + id: '17573', + category_id: 'vclub', + series: [1, 2, 3], + info: { name: 'Kholod' }, + }, + } as ActivityStub, + ]; + const service = createService(); + + await service.load(); + + const tvTitles = service + .items() + .filter((item) => item.mediaType === 'tv') + .map((item) => item.title); + // Guard: the rail really is carrying tv candidates here, so the + // assertion below cannot pass on an empty list. + expect(tvTitles.length).toBeGreaterThan(0); + expect(tvTitles).not.toContain('Kholod'); + }); + + it('does not let a watched vclub series exclude a same-named film', async () => { + // The series routes as 'movie' but is positively identified as a + // show, so the routing key must not survive and swallow an + // unrelated movie of the same name. + enrichMovie.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), + rec(200, 'Kholod'), + ], + }, + }); + recentAll = [ + ...recentVod, + { + title: 'Kholod', + type: 'movie', + stalker_item: { + id: '17573', + category_id: 'vclub', + series: [1, 2, 3], + info: { name: 'Kholod' }, + }, + } as ActivityStub, + ]; + const service = createService(); + + await service.load(); + + expect(service.items().map((item) => item.title)).toContain('Kholod'); + }); + + it('links a known-year recommendation to the year-tagged catalog row', async () => { + // An untagged "Dune" row could be either cut; "Dune 2021" is + // positive evidence for this exact film. + enrichMovie.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), + rec(200, 'Dune', 2021), + ], + }, + }); + matchTitles.mockImplementation(async (titles: string[]) => { + const rows: CatalogTitleMatch[] = []; + for (const title of titles) { + if (title === 'Dune') { + rows.push( + match(title, { trailingYear: null, xtreamId: 1984 }), + match(title, { trailingYear: 2021, xtreamId: 2021 }) + ); + } else { + rows.push(match(title)); + } + } + return rows; + }); + const service = createService(); + + await service.load(); + + const dune = service.items().find((item) => item.title === 'Dune'); + expect(dune?.match.xtreamId).toBe(2021); + }); + + it('prefers a year-tagged alias row over an untagged localized one', async () => { + // "The Hunt" is ambiguous (2012 and 2020 films share it); the + // original-title alias holds a row tagged with the candidate's + // own year, which is the stronger evidence whichever alias it + // came from. + enrichMovie.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), + { + ...rec(200, 'The Hunt', 2012), + original_title: 'Jagten', + }, + ], + }, + }); + matchTitles.mockImplementation(async (titles: string[]) => { + const rows: CatalogTitleMatch[] = []; + for (const title of titles) { + if (title === 'The Hunt') { + rows.push( + match(title, { trailingYear: null, xtreamId: 999 }) + ); + } else if (title === 'Jagten') { + rows.push( + match(title, { trailingYear: 2012, xtreamId: 2012 }) + ); + } else { + rows.push(match(title)); + } + } + return rows; + }); + const service = createService(); + + await service.load(); + + const hunt = service + .items() + .find((item) => item.title === 'The Hunt'); + expect(hunt?.match.xtreamId).toBe(2012); + }); + + it('excludes a watched Stalker item through its stored original title', async () => { + // Watched entry stored in the original language; the app now + // requests TMDB in another one, so the candidate is translated. + enrichMovie.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), + rec(200, 'Inception'), + ], + }, + }); + recentAll = [ + ...recentVod, + { + title: 'Начало', + type: 'movie', + stalker_item: { + id: '42', + category_id: 'vod', + info: { name: 'Начало', o_name: 'Inception' }, + }, + } as ActivityStub, + ]; + const service = createService(); + + await service.load(); + + expect(service.items().map((item) => item.title)).not.toContain( + 'Inception' + ); + }); + + it('does not let a watched film exclude the same-named show', async () => { + // A plain 'movie' row states nothing about being a series, so the + // lookup builder's tv retry is a guess — indexing it would make a + // watched film swallow the unrelated show of the same name. + seedTvRecommendations(['Fargo']); + recentAll = [...recentVod, { title: 'Fargo', type: 'movie' }]; + const service = createService(); + + await service.load(); + + expect(service.items().map((item) => item.title)).toContain('Fargo'); + }); + + it('excludes a watched title stored with a trailing release year', async () => { + // Panels routinely name the file "Inception 2010"; its exact key + // can never equal TMDB's canonical "Inception". + recentAll = [...recentVod, { title: 'Inception 2010', type: 'movie' }]; + const service = createService(); + + await service.load(); + + expect(service.items().map((item) => item.title)).not.toContain( + 'Inception' + ); + }); + + it('does not let a year-suffixed title swallow a different film', async () => { + // "Blade Runner 2049" carries a year in its NAME — the 1982 film + // shares the base title but is a different movie. + enrichMovie.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), + rec(200, 'Blade Runner', 1982), + ], + }, + }); + recentAll = [ + ...recentVod, + { title: 'Blade Runner 2049', type: 'movie' }, + ]; + const service = createService(); + + await service.load(); + + expect(service.items().map((item) => item.title)).toContain( + 'Blade Runner' + ); + }); + + it('drops year-incompatible matches', async () => { + matchTitles.mockImplementation(async (titles: string[]) => + titles.map((title) => + match(title, { + trailingYear: title === 'Inception' ? 1974 : null, + }) + ) + ); + const service = createService(); + + await service.load(); + + expect(service.items().map((item) => item.title)).not.toContain( + 'Inception' + ); + }); + + it('loads once per seed set and re-seeds when it changes', async () => { + const service = createService(); + + await service.load(); + await service.load(); + expect(enrichMovie).toHaveBeenCalledTimes(1); + + recentVod = [{ title: 'Heat', type: 'movie' }]; + recentAll = [...recentVod]; + await service.load(); + expect(enrichMovie).toHaveBeenCalledTimes(2); + }); + + it('clears the rail when the watch history empties', async () => { + const service = createService(); + + await service.load(); + expect(service.items()).toHaveLength(recTitles.length); + + recentVod = []; + recentAll = []; + await service.load(); + + expect(service.items()).toEqual([]); + expect(service.seedTitles()).toEqual([]); + }); + + it('re-filters when a recommended title is favorited after a load', async () => { + const service = createService(); + + await service.load(); + expect(service.items().map((item) => item.title)).toContain( + 'Inception' + ); + + favorites = [{ title: 'Inception', type: 'movie' }]; + await service.load(); + + expect(service.items().map((item) => item.title)).not.toContain( + 'Inception' + ); + }); + + it('runs a load queued while another was in flight', async () => { + let resolveFirst!: (value: unknown) => void; + enrichMovie.mockImplementationOnce( + () => new Promise((resolve) => (resolveFirst = resolve)) + ); + const service = createService(); + + const first = service.load(); + recentVod = [{ title: 'Heat', type: 'movie' }]; + recentAll = [...recentVod]; + await service.load(); // dropped by the loading guard — must queue + + resolveFirst({ + recommendations: { + results: recTitles.map((title, i) => rec(100 + i, title)), + }, + }); + await first; // resolves only after the queued re-run completes + + expect(enrichMovie).toHaveBeenCalledTimes(2); + expect(enrichMovie.mock.calls[1][0]).toEqual( + expect.objectContaining({ title: 'Heat' }) + ); + }); + + it('recovers a previously successful input set after a hidden interlude', async () => { + const service = createService(); + + await service.load(); + expect(service.items()).toHaveLength(recTitles.length); + + // A transient below-threshold result under different inputs + favorites = [{ title: 'Inception', type: 'movie' }]; + matchTitles.mockImplementationOnce(async (titles: string[]) => + titles.slice(0, 4).map((title) => match(title)) + ); + await service.load(); + expect(service.items()).toEqual([]); + + // Returning to the original inputs must reload, not hit the guard + favorites = []; + await service.load(); + expect(service.items()).toHaveLength(recTitles.length); + }); + + it('reloads when the TMDB language changes', async () => { + const service = createService(); + + await service.load(); + expect(enrichMovie).toHaveBeenCalledTimes(1); + + tmdbLanguage = 'ru-RU'; + await service.load(); + expect(enrichMovie).toHaveBeenCalledTimes(2); + }); + + it('picks the year-compatible row when the catalog holds several', async () => { + // Two year-stripped rows share one key; the wrong one arrives + // first. Collapsing before the year check would discard the right + // one and drop the card. + enrichMovie.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), + rec(200, 'Dune', 2021), + ], + }, + }); + matchTitles.mockImplementation(async (titles: string[]) => { + const rows: CatalogTitleMatch[] = []; + for (const title of titles) { + if (title === 'Dune') { + rows.push( + match(title, { trailingYear: 1984, xtreamId: 84 }), + match(title, { trailingYear: 2021, xtreamId: 21 }) + ); + } else { + rows.push(match(title)); + } + } + return rows; + }); + const service = createService(); + + await service.load(); + + const dune = service.items().find((item) => item.title === 'Dune'); + expect(dune).toBeDefined(); + expect(dune?.match.xtreamId).toBe(21); + }); + + it('falls back to the alias match when the localized match is year-incompatible', async () => { + enrichMovie.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), + { + ...rec(200, 'The Hunt', 2012), + original_title: 'Jagten', + }, + ], + }, + }); + matchTitles.mockImplementation(async (titles: string[]) => + titles.map((title) => + match(title, { + // The localized title hits a same-named 2020 row; the + // original-title alias holds the correct 2012 match. + trailingYear: title === 'The Hunt' ? 2020 : null, + }) + ) + ); + const service = createService(); + + await service.load(); + + const hunt = service + .items() + .find((item) => item.title === 'The Hunt'); + expect(hunt).toBeDefined(); + expect(hunt?.match.queryTitle).toBe('Jagten'); + }); + + it('re-runs the matching when the playlist set changes', async () => { + const service = createService(); + + await service.load(); + expect(matchTitles).toHaveBeenCalledTimes(1); + + playlists = [{ _id: 'pl-1' }, { _id: 'pl-2' }]; + await service.load(); + expect(matchTitles).toHaveBeenCalledTimes(2); + }); + + it('matches and excludes through the original-title alias', async () => { + // App language localizes TMDB titles; the catalog and the stored + // watch history speak the original language. + enrichMovie.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), + { + ...rec(200, 'Начало'), + original_title: 'Inception 2', + }, + { + ...rec(201, 'Слышь, смотрел?'), + original_title: 'Dunkirk 2', + }, + ], + }, + }); + matchTitles.mockImplementation(async (titles: string[]) => + titles + .filter((title) => /^[A-Za-z]/.test(title)) + .map((title) => match(title)) + ); + recentAll = [...recentVod, { title: 'Dunkirk 2', type: 'movie' }]; + const service = createService(); + + await service.load(); + + const titles = service.items().map((item) => item.title); + // Matched via its original title, displayed localized + expect(titles).toContain('Начало'); + // Excluded because its original title was already watched + expect(titles).not.toContain('Слышь, смотрел?'); + }); + + it('keeps the rail when a refresh cannot reach TMDB', async () => { + const service = createService(); + + await service.load(); + expect(service.items()).toHaveLength(recTitles.length); + + // New seed, TMDB unreachable: a failed refresh is not a verdict + // that there is nothing to recommend. + recentVod = [{ title: 'Heat', type: 'movie' }]; + recentAll = [...recentVod]; + enrichMovie.mockResolvedValue(null); + await service.load(); + + expect(service.items()).toHaveLength(recTitles.length); + }); + + it('drops a since-favorited card even when the refresh fails', async () => { + const service = createService(); + + await service.load(); + expect(service.items().map((item) => item.title)).toContain( + 'Inception' + ); + + favorites = [{ title: 'Inception', type: 'movie' }]; + enrichMovie.mockResolvedValue(null); + await service.load(); + + const titles = service.items().map((item) => item.title); + expect(titles).not.toContain('Inception'); + // The rest survive — the failure is not a reason to blank them + expect(titles).toHaveLength(recTitles.length - 1); + }); + + it('rebuilds after an offline filter once the inputs are restored', async () => { + const service = createService(); + + await service.load(); + const full = service.items().length; + + // Offline, and enough cards favorited to fall under the threshold + favorites = recTitles.slice(0, 3).map((title) => ({ + title, + type: 'movie', + })); + enrichMovie.mockResolvedValue(null); + await service.load(); + expect(service.items()).toEqual([]); + + // Un-favoriting restores the exact inputs of the successful load — + // the saved key must not short-circuit the rebuild. + favorites = []; + enrichMovie.mockResolvedValue({ + recommendations: { + results: recTitles.map((title, i) => rec(100 + i, title)), + }, + }); + await service.load(); + expect(service.items()).toHaveLength(full); + }); + + it('keeps same-titled remakes apart through catalog matching', async () => { + // Two seeds recommend two different films sharing a title; only + // the 2021 one is in the library. Collapsing them before matching + // would let the 1984 entry fail the year gate on its behalf. + recentVod = [ + { title: 'The Matrix', type: 'movie' }, + { title: 'Blade Runner', type: 'movie' }, + ]; + recentAll = [...recentVod]; + enrichMovie.mockImplementation(async (query: { title: string }) => ({ + recommendations: { + results: + query.title === 'The Matrix' + ? [ + // The unplayable cut is interleaved FIRST — + // that is the order in which collapsing by + // title loses the playable one. + rec(300, 'Dune', 1984), + ...recTitles + .slice(0, 5) + .map((t, i) => rec(100 + i, t)), + ] + : [rec(301, 'Dune', 2021)], + }, + })); + matchTitles.mockImplementation(async (titles: string[]) => { + const rows: CatalogTitleMatch[] = []; + for (const title of titles) { + // The library holds only the 2021 cut + rows.push( + title === 'Dune' + ? match(title, { trailingYear: 2021, xtreamId: 21 }) + : match(title) + ); + } + return rows; + }); + const service = createService(); + + await service.load(); + + const dune = service.items().filter((item) => item.title === 'Dune'); + expect(dune).toHaveLength(1); + expect(dune[0].year).toBe(2021); + }); + + it('does not exclude a remake whose year disagrees with the watched one', async () => { + // A Stalker row states its release year, so a watched 1954 + // "Godzilla" must not swallow the 2014 one. + enrichMovie.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), + rec(200, 'Godzilla', 2014), + ], + }, + }); + recentAll = [ + ...recentVod, + { + title: 'Godzilla', + type: 'movie', + stalker_item: { + id: '9', + category_id: 'vod', + info: { name: 'Godzilla', releasedate: '1954-11-03' }, + }, + } as ActivityStub, + ]; + const service = createService(); + + await service.load(); + + expect(service.items().map((item) => item.title)).toContain( + 'Godzilla' + ); + }); + + it('excludes a watched title whose name ends in a year', async () => { + // "Blade Runner 2049" is a 2017 film; the 2049 belongs to the + // NAME, so it must not be read as a release year and used to + // reject the very title the user just watched. + enrichMovie.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), + rec(200, 'Blade Runner 2049', 2017), + ], + }, + }); + recentAll = [ + ...recentVod, + { title: 'Blade Runner 2049', type: 'movie' }, + ]; + const service = createService(); + + await service.load(); + + expect(service.items().map((item) => item.title)).not.toContain( + 'Blade Runner 2049' + ); + }); + + it('drops a card whose playlist was deleted while offline', async () => { + const service = createService(); + + await service.load(); + expect(service.items().length).toBeGreaterThan(0); + + // Playlist gone AND TMDB unreachable — the only path that reaches + // the retained cards without rebuilding them. + playlists = []; + enrichMovie.mockResolvedValue(null); + await service.load(); + + expect(service.items()).toEqual([]); + }); + + it('still excludes a watched title whose year is unknown', async () => { + recentAll = [...recentVod, { title: 'Inception', type: 'movie' }]; + const service = createService(); + + await service.load(); + + expect(service.items().map((item) => item.title)).not.toContain( + 'Inception' + ); + }); + + it('retries while any seed is still unresolved', async () => { + // Two seeds, one transiently failing: the resolved one alone + // fills the rail, but latching would drop the other seed's + // recommendations for the rest of the session. + recentVod = [ + { title: 'The Matrix', type: 'movie' }, + { title: 'Blade Runner', type: 'movie' }, + ]; + recentAll = [...recentVod]; + enrichMovie.mockImplementation(async (query: { title: string }) => + query.title === 'The Matrix' + ? { + recommendations: { + results: recTitles.map((t, i) => rec(100 + i, t)), + }, + } + : null + ); + const service = createService(); + + await service.load(); + expect(service.items()).toHaveLength(recTitles.length); + const callsAfterFirst = enrichMovie.mock.calls.length; + + await service.load(); + expect(enrichMovie.mock.calls.length).toBeGreaterThan(callsAfterFirst); + }); + + it('latches once every seed has resolved', async () => { + const service = createService(); + + await service.load(); + await service.load(); + + expect(enrichMovie).toHaveBeenCalledTimes(1); + }); + + it('retries on the next visit when no seed resolved', async () => { + enrichMovie.mockResolvedValue(null); + const service = createService(); + + await service.load(); + expect(service.items()).toEqual([]); + + enrichMovie.mockResolvedValue({ + recommendations: { + results: recTitles.map((title, i) => rec(100 + i, title)), + }, + }); + await service.load(); + expect(service.items()).toHaveLength(recTitles.length); + }); + + it('interleaves seeds and deduplicates shared recommendations', async () => { + recentVod = [ + { title: 'The Matrix', type: 'movie' }, + { title: 'Blade Runner', type: 'movie' }, + ]; + recentAll = [...recentVod]; + enrichMovie.mockImplementation(async (query: { title: string }) => ({ + recommendations: { + results: + query.title === 'The Matrix' + ? recTitles.slice(0, 4).map((t, i) => rec(100 + i, t)) + : [ + // Shared with the first seed — must appear once + rec(100, 'Inception'), + rec(300, 'Ghost in the Shell'), + rec(301, 'Akira'), + ], + }, + })); + const service = createService(); + + await service.load(); + + const titles = service.items().map((item) => item.title); + expect(titles.filter((title) => title === 'Inception')).toHaveLength(1); + // Round-robin: the second seed's first unique pick lands before the + // first seed's tail. + expect(titles.indexOf('Ghost in the Shell')).toBeLessThan( + titles.indexOf('Dunkirk') + ); + expect(service.seedTitles()).toEqual(['The Matrix', 'Blade Runner']); + }); + + it('reports only the seeds that contributed visible items', async () => { + recentVod = [ + { title: 'The Matrix', type: 'movie' }, + { title: 'Blade Runner', type: 'movie' }, + ]; + recentAll = [...recentVod]; + enrichMovie.mockImplementation(async (query: { title: string }) => ({ + recommendations: { + results: + query.title === 'The Matrix' + ? recTitles.map((t, i) => rec(100 + i, t)) + : [], + }, + })); + const service = createService(); + + await service.load(); + + expect(service.seedTitles()).toEqual(['The Matrix']); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts new file mode 100644 index 000000000..223b3a47d --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts @@ -0,0 +1,451 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { + CatalogTitleMatchService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { normalizeTitleKeys } from '@iptvnator/shared/interfaces'; +import { DashboardDataService } from './dashboard-data.service'; +import { + DashboardTmdbLookupItem, + buildDashboardTmdbAttempts, + dashboardTmdbLookupKey, +} from './dashboard-tmdb-lookup.util'; +import { + DashboardRecommendationItem, + ExclusionIndex, + RecommendationCandidate, + buildLoadKey, + groupMatchesByKey, + isExcludedCandidate, + pickCatalogMatch, + toCandidates, + trustedReleaseYear, +} from './dashboard-recommendations.util'; + +interface SeedRecommendations { + resolved: boolean; + seedTitle: string; + entries: RecommendationCandidate[]; +} + +/** + * Fewer confident matches than this hides the rail entirely — a rail of + * two cards reads worse than no rail. + */ +export const MIN_RECOMMENDATION_MATCHES = 5; +const MAX_SEEDS = 3; +const MAX_ITEMS = 18; + +/** + * "Because you watched" dashboard rail data. TMDB has no account-free + * "recommendations for you" endpoint, so the rail is seeded from the + * user's most recently watched movies/series: each seed resolves through + * the enrichment facade (items whose detail view was opened come straight + * from the SQLite cache, where `recommendations` ride along with every + * details payload), the per-seed lists are interleaved and deduplicated, + * already watched/favorited titles are dropped, and ONE batched worker + * request keeps only titles that exist in an imported library. + * + * Requires the TMDB opt-in and the Electron DB worker — hidden in the PWA. + * A load is keyed by the seed set AND the watched/favorited exclusion set, + * so watching something new re-seeds the rail and favoriting a recommended + * title removes it on the next dashboard visit; failed loads (TMDB + * unreachable) do not latch and retry instead. A load requested while one + * is in flight is queued and re-run afterwards, so a mid-flight history + * change cannot strand a stale rail. + */ +@Injectable({ providedIn: 'root' }) +export class DashboardRecommendationsService { + private readonly enrichment = inject(TmdbEnrichmentService); + private readonly titleMatch = inject(CatalogTitleMatchService); + private readonly data = inject(DashboardDataService); + + readonly items = signal([]); + /** Seeds that contributed at least one visible card, most recent first */ + readonly seedTitles = signal([]); + readonly loading = signal(false); + + private loadedKey: string | null = null; + private rerunQueued = false; + + get isAvailable(): boolean { + return this.enrichment.isEnabled() && this.titleMatch.isAvailable; + } + + async load(): Promise { + if (!this.isAvailable) { + return; + } + if (this.loading()) { + // Re-run after the active load settles — its result may be for + // a seed/exclusion set that just went stale. + this.rerunQueued = true; + return; + } + const seeds = this.selectSeeds(); + if (seeds.length === 0) { + // The service outlives the dashboard (root-provided), so a + // cleared watch history must clear the rail too. + this.items.set([]); + this.seedTitles.set([]); + this.loadedKey = null; + return; + } + const excluded = this.buildExclusionIndex(); + // The language is part of the identity: TMDB payloads (and thus + // card titles) are localized, so a language change must reload — + // the service outlives the dashboard and would otherwise keep + // titles in the previous language all session. + const loadKey = `${this.enrichment.language()}//${buildLoadKey( + seeds, + excluded, + this.catalogKey() + )}`; + if (loadKey === this.loadedKey) { + return; + } + + this.loading.set(true); + try { + const perSeed = await Promise.all( + seeds.map((seed) => this.recommendationsForSeed(seed)) + ); + // No seed resolved: TMDB is likely unreachable or every lookup + // missed. Do NOT latch — the next dashboard visit retries — + // and do NOT blank the rail either: a failed refresh is not a + // verdict that there is nothing to recommend, and removing + // still-valid cards is the worse answer for an offline user. + // Only the cards the user has meanwhile watched or favorited + // are dropped, since those the failure cannot excuse. + if (!perSeed.some((seed) => seed.resolved)) { + this.dropExcludedCards(excluded); + } else { + const candidates = this.mergeCandidates( + perSeed.map((seed) => seed.entries), + excluded + ); + const matched = await this.attachMatches(candidates); + if (matched.length >= MIN_RECOMMENDATION_MATCHES) { + const contributed = new Set( + matched.map((item) => item.seedTitle) + ); + this.items.set(matched); + this.seedTitles.set( + perSeed + .map((seed) => seed.seedTitle) + .filter((title) => contributed.has(title)) + ); + // Latch only once EVERY seed answered. A seed that did + // not resolve may have failed transiently, and latching + // on its behalf would drop its recommendations for the + // rest of the session. A seed that simply has no TMDB + // match never resolves either, so this rail re-runs on + // each visit for that user — bounded work, since the + // enrichment misses are cached and the catalog match is + // one batched worker call. + this.loadedKey = perSeed.every((seed) => seed.resolved) + ? loadKey + : null; + } else { + // Below the threshold the rail is hidden — and NOT + // latched: an empty match result is indistinguishable + // from a transient worker failure at this layer + // (matchTitles maps failures to []), and re-running is + // cheap (cached enrichment + one batched worker call). + // Mirrors the trending rail's retry-on-empty semantics. + // The PREVIOUS key must reset too: it described the + // rail that was just cleared, and returning to those + // exact inputs (say, un-favoriting again) would + // otherwise hit the equality guard and stay empty. + this.items.set([]); + this.seedTitles.set([]); + this.loadedKey = null; + } + } + } catch (error) { + console.warn('Dashboard recommendations load failed:', error); + } finally { + this.loading.set(false); + } + + if (this.rerunQueued) { + this.rerunQueued = false; + await this.load(); + } + } + + /** + * Re-filter the cards already on screen against a freshly built + * exclusion index, used when a refresh could not reach TMDB. Keeps + * the rail useful offline while making sure a title the user watched + * or favorited since the last successful load cannot linger. Falling + * under the match threshold hides the rail, as everywhere else. + */ + private dropExcludedCards(excluded: ExclusionIndex): void { + const current = this.items(); + // A card whose playlist is gone would navigate to a dead route, + // and the failed refresh is no excuse for keeping it — this is + // the only path that can reach a deleted playlist without the + // catalog key rebuilding the rail from scratch. + const livePlaylists = new Set( + this.data.playlists().map((playlist) => playlist._id) + ); + const kept = current.filter( + (item) => + livePlaylists.has(item.match.playlistId) && + !isExcludedCandidate(item, excluded) + ); + if (kept.length === current.length) { + return; + } + + // What is on screen is no longer the result of any completed + // load, so the saved key must stop describing it — otherwise + // restoring those exact inputs (un-favoriting the title again) + // would hit the equality guard and leave the rail as it is now. + this.loadedKey = null; + + if (kept.length < MIN_RECOMMENDATION_MATCHES) { + this.items.set([]); + this.seedTitles.set([]); + return; + } + + const contributed = new Set(kept.map((item) => item.seedTitle)); + this.items.set(kept); + this.seedTitles.set( + this.seedTitles().filter((title) => contributed.has(title)) + ); + } + + /** + * Identity of the imported-playlist set. Included in the load key so + * importing or deleting a playlist re-runs the catalog matching — + * without it a deleted playlist would leave cards linking nowhere and + * a new import would stay invisible until the seeds changed. A + * refreshed playlist keeps its id and is NOT detected (parity with + * the trending rail's once-per-session load). + */ + private catalogKey(): string { + return this.data + .playlists() + .map((playlist) => playlist._id) + .sort() + .join(','); + } + + /** Most recent distinct watched VOD/series usable as TMDB lookups */ + private selectSeeds(): DashboardTmdbLookupItem[] { + const seeds: DashboardTmdbLookupItem[] = []; + const seen = new Set(); + for (const item of this.data.globalRecentVodItems()) { + if (buildDashboardTmdbAttempts(item).length === 0) { + continue; + } + const key = dashboardTmdbLookupKey(item); + if (seen.has(key)) { + continue; + } + seen.add(key); + seeds.push(item); + if (seeds.length === MAX_SEEDS) { + break; + } + } + return seeds; + } + + private async recommendationsForSeed( + seed: DashboardTmdbLookupItem + ): Promise { + for (const attempt of buildDashboardTmdbAttempts(seed)) { + const query = { + title: attempt.title, + originalTitle: attempt.originalTitle, + tmdbId: attempt.tmdbId, + year: attempt.year, + }; + const details = + attempt.mediaType === 'tv' + ? await this.enrichment.enrichTv(query) + : await this.enrichment.enrichMovie(query); + if (details) { + return { + resolved: true, + seedTitle: attempt.title, + entries: toCandidates( + details.recommendations?.results ?? [], + attempt.mediaType, + attempt.title + ), + }; + } + } + return { resolved: false, seedTitle: seed.title, entries: [] }; + } + + /** + * Round-robin across the per-seed lists so one seed cannot crowd out + * the others, dropping duplicates and anything the user has already + * watched or favorited. + */ + private mergeCandidates( + lists: readonly RecommendationCandidate[][], + excluded: ExclusionIndex + ): RecommendationCandidate[] { + const seen = new Set(); + const merged: RecommendationCandidate[] = []; + const longest = Math.max(0, ...lists.map((list) => list.length)); + for (let i = 0; i < longest; i++) { + for (const list of lists) { + const entry = list[i]; + if (!entry) { + continue; + } + // Deduplicated by TMDB identity only. Same-titled + // remakes ("Dune" 1984 and 2021) are DIFFERENT films and + // must both reach the catalog matching — collapsing them + // here would let whichever arrived first fail the year + // gate on behalf of the one the library actually holds. + // Two candidates that end up on the same catalog row are + // collapsed after matching instead. + const idKey = `${entry.mediaType}:${entry.tmdbId}`; + if (seen.has(idKey) || isExcludedCandidate(entry, excluded)) { + continue; + } + seen.add(idKey); + merged.push(entry); + } + } + return merged; + } + + /** + * What the user has already watched or favorited, keyed the way a + * recommendation will be looked up. + * + * An activity row is indexed under more than its display title, + * because two things about it can disagree with TMDB: + * + * - its TYPE is a routing verdict, not a media type. A Stalker + * embedded-VOD series routes into the VOD section and is stored as + * `'movie'`, while TMDB knows it as a show — so the show's + * recommendation would look up `series:` and sail past a + * `movie:`-only entry. The lookup builder already resolves the + * media type the detail view enriched under; that verdict is + * indexed alongside the routing one. + * - its TITLE may be the original-language one (`info.o_name`) while + * the app requests TMDB in another language, so the candidate + * carries a translated title and no shared key. + * + * Only the PRIMARY attempt is indexed. The builder's second attempt + * is a fallback guess for rows that state nothing, and indexing it + * would let a watched film exclude the same-named show. + */ + private buildExclusionIndex(): ExclusionIndex { + const exact = new Map(); + const baseYears = new Map(); + + const addTitle = ( + type: 'movie' | 'series', + title: string | undefined, + year: number | null + ): void => { + const keys = normalizeTitleKeys(title); + if (!keys.exact) { + return; + } + const exactKey = `${type}:${keys.exact}`; + exact.set(exactKey, [...(exact.get(exactKey) ?? []), year]); + // Providers routinely store titles with a trailing release + // year ("Inception 2010") whose exact key can never equal the + // canonical TMDB title. Record the base + year so the merge + // can exclude the canonical form when the years agree. + if (keys.trailingYear !== null) { + const baseKey = `${type}:${keys.base}`; + baseYears.set(baseKey, [ + ...(baseYears.get(baseKey) ?? []), + keys.trailingYear, + ]); + } + }; + + const add = (item: DashboardTmdbLookupItem): void => { + if (item.type !== 'movie' && item.type !== 'series') { + return; + } + // Only a year the row STATES in a metadata field gates the + // exact tier, so a watched 1954 "Godzilla" cannot exclude the + // 2014 one — while "Blade Runner 2049", whose year is part of + // the name, still excludes itself. `null` keeps the + // conservative behaviour for rows that state no year. + const year = trustedReleaseYear(item); + const [primary] = buildDashboardTmdbAttempts(item); + // The resolved media type REPLACES the routing one rather + // than joining it: a Stalker embedded-VOD series routes as + // 'movie' on positive series evidence, and keeping that key + // would make the watched show exclude an unrelated film of + // the same name. Rows the builder cannot classify at all keep + // their routing type, which is then the only thing known. + const type = primary + ? primary.mediaType === 'tv' + ? 'series' + : 'movie' + : item.type; + + addTitle(type, item.title, year); + if (primary) { + addTitle(type, primary.title, year); + addTitle(type, primary.originalTitle, year); + } + }; + + this.data.globalRecentItems().forEach(add); + this.data.globalFavoriteItems().forEach(add); + return { exact, baseYears }; + } + + private async attachMatches( + candidates: readonly RecommendationCandidate[] + ): Promise { + if (candidates.length === 0) { + return []; + } + // Both aliases go into the ONE batched request; the index lookup + // below prefers the localized form. Built with a loop rather than + // flatMap — the web app compiles this lib against `lib: es2018`, + // which predates Array.prototype.flatMap. + const queryTitles: string[] = []; + for (const candidate of candidates) { + queryTitles.push(candidate.title); + if (candidate.originalTitle) { + queryTitles.push(candidate.originalTitle); + } + } + const matches = await this.titleMatch.matchTitles(queryTitles); + const grouped = groupMatchesByKey(matches); + + // Title collisions are resolved HERE rather than before matching: + // two candidates that resolve to the same catalog row would render + // as duplicate cards opening the same item, while same-titled + // remakes resolve to different rows and both belong on the rail. + const items: DashboardRecommendationItem[] = []; + const claimedRows = new Set(); + for (const candidate of candidates) { + const match = pickCatalogMatch(candidate, grouped); + if (!match) { + continue; + } + const rowKey = `${match.playlistId}:${match.type}:${match.xtreamId}`; + if (claimedRows.has(rowKey)) { + continue; + } + claimedRows.add(rowKey); + items.push({ ...candidate, match }); + if (items.length === MAX_ITEMS) { + break; + } + } + return items; + } +} diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts new file mode 100644 index 000000000..8dc85eec4 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts @@ -0,0 +1,272 @@ +import { extractYear, tmdbPosterUrl } from '@iptvnator/services'; +import type { TmdbSearchResult } from '@iptvnator/services'; +import { + CatalogTitleMatch, + normalizeTitleKeys, + titleYearsCompatible, +} from '@iptvnator/shared/interfaces'; +import { + DashboardTmdbLookupItem, + dashboardTmdbLookupKey, +} from './dashboard-tmdb-lookup.util'; + +/** One recommendation card: TMDB entry + the library match that makes it playable */ +export interface DashboardRecommendationItem { + tmdbId: number; + mediaType: 'movie' | 'tv'; + title: string; + /** + * TMDB original title when it differs from the localized one — a + * matching/exclusion alias, never displayed. Catalogs frequently name + * an item in its original language while the app language localizes + * the TMDB title, so matching on the localized form alone would hide + * available recommendations. + */ + originalTitle: string | null; + year: number | null; + posterUrl: string | null; + /** vote_average rounded to one decimal, null without votes */ + rating: string | null; + /** Confident match in an imported Xtream playlist — unmatched entries are dropped */ + match: CatalogTitleMatch; + /** Display title of the watched item this recommendation came from */ + seedTitle: string; +} + +export type RecommendationCandidate = Omit; + +/** + * The release year an activity row STATES in a metadata field, never one + * parsed out of its title. + * + * The exact tier compares whole normalized titles, so a trailing number + * there belongs to the NAME: "Blade Runner 2049" is not a 2049 film, and + * gating that key on a title-derived 2049 would fail to exclude the very + * film the user just watched (TMDB calls it 2017). Only Stalker rows + * carry a real date (`info.releasedate`); everything else yields `null`, + * which keeps the exact tier excluding unconditionally. + * + * The base tier is different by construction — its key exists only + * because a trailing year was stripped off — so it keeps using that year. + */ +export function trustedReleaseYear( + item: DashboardTmdbLookupItem +): number | null { + const info = ( + item.stalker_item as { info?: Record } | undefined + )?.info; + const releaseDate = info?.['releasedate']; + return typeof releaseDate === 'string' ? extractYear(releaseDate) : null; +} + +/** + * What the user has already watched or favorited, in the two forms a + * recommendation can collide with. + */ +export interface ExclusionIndex { + /** + * `type:exactNormalizedTitle` → the release years of the rows that + * produced it. `null` means the row stated no year, which keeps the + * conservative "exclude anyway" behaviour for that entry. + */ + readonly exact: ReadonlyMap; + /** + * `type:baseNormalizedTitle` → the trailing years stripped from the + * stored titles that produced it. Only titles that HAD a trailing + * year appear here, so the base tier is always year-gated. + */ + readonly baseYears: ReadonlyMap; +} + +/** Normalized keys for one candidate alias, both matching tiers */ +interface CandidateKeys { + readonly exact: string; + readonly base: string; +} + +/** + * Both matching tiers for each of the candidate's aliases — localized + * title first, original-title alias second. + */ +function candidateKeySets( + candidate: RecommendationCandidate +): CandidateKeys[] { + const type = candidate.mediaType === 'movie' ? 'movie' : 'series'; + const toKeys = (title: string): CandidateKeys => { + const keys = normalizeTitleKeys(title); + return { exact: `${type}:${keys.exact}`, base: `${type}:${keys.base}` }; + }; + + const sets = [toKeys(candidate.title)]; + if (candidate.originalTitle) { + const alias = toKeys(candidate.originalTitle); + if (alias.exact !== sets[0].exact) { + sets.push(alias); + } + } + return sets; +} + +/** Exact-tier keys only — used for dedupe and catalog-match lookup */ +function candidateTitleKeys(candidate: RecommendationCandidate): string[] { + return candidateKeySets(candidate).map((keys) => keys.exact); +} + +/** + * EVERY match per `type:exactNormalizedTitle`, in the order the worker + * returned them. + * + * Deliberately not the shared `buildTitleMatchIndex`: that collapses to + * one row per key before the candidate's year is known, so a catalog + * holding both "Dune 1984" and "Dune 2021" keeps whichever arrived first + * and a 2021 recommendation then fails the year check with the right row + * already discarded. Keeping every row lets the year gate choose. + */ +export function groupMatchesByKey( + matches: readonly CatalogTitleMatch[] +): Map { + const grouped = new Map(); + for (const match of matches) { + const key = `${match.type}:${normalizeTitleKeys(match.queryTitle).exact}`; + grouped.set(key, [...(grouped.get(key) ?? []), match]); + } + return grouped; +} + +/** + * The catalog row this recommendation should link to, or null. + * + * Aliases are tried in order (localized title, then original-title), and + * only year-compatible rows qualify — a localized title can hit a + * same-named different-year row while the alias holds the correct match, + * so a bad hit must not veto the good one. + * + * Ranking is by EVIDENCE, across every alias at once. A row whose + * stripped year IS the candidate's wins: that is positive evidence for + * this exact film, while a row with no year is merely not contradicting + * one ("Dune" could be either cut, so linking a 2021 recommendation to it + * when "Dune 2021" also exists throws the better evidence away — and that + * holds whichever alias found which). Untagged rows come next — mirroring + * `buildTitleMatchIndex`'s precedence, and the only tier reachable when + * the candidate's own year is unknown — then anything else compatible. + * Alias order survives only as the tiebreaker inside a tier. + */ +export function pickCatalogMatch( + candidate: RecommendationCandidate, + grouped: ReadonlyMap +): CatalogTitleMatch | null { + // Every alias contributes to one pool, ranked by evidence rather than + // by which alias found it: an untagged row under the localized title + // must not outrank a row the original-title alias found carrying the + // candidate's own year. Rows enter in alias order, so `find` still + // breaks ties the old way — localized first. + const compatible: CatalogTitleMatch[] = []; + for (const key of candidateTitleKeys(candidate)) { + for (const row of grouped.get(key) ?? []) { + if (titleYearsCompatible(candidate.year, row.trailingYear)) { + compatible.push(row); + } + } + } + + return ( + (candidate.year !== null + ? compatible.find((row) => row.trailingYear === candidate.year) + : undefined) ?? + compatible.find((row) => row.trailingYear === null) ?? + compatible[0] ?? + null + ); +} + +/** + * Whether the user has already watched or favorited this recommendation. + * + * Two tiers, because a provider stores whatever the panel named the file. + * The exact tier catches a stored title that normalizes to the canonical + * one. The base tier catches the common `"Inception 2010"` shape, whose + * exact key can never equal TMDB's `"Inception"` — but only when the + * years agree, so a stored `"Blade Runner 2049"` does not swallow the + * 1982 film. An unknown year on either side counts as agreeing + * (`titleYearsCompatible`): recommending something already watched is the + * worse failure of the two. + */ +export function isExcludedCandidate( + candidate: RecommendationCandidate, + excluded: ExclusionIndex +): boolean { + const yearAgrees = (year: number | null): boolean => + year === null || titleYearsCompatible(candidate.year, year); + + return candidateKeySets(candidate).some( + ({ exact, base }) => + (excluded.exact.get(exact) ?? []).some(yearAgrees) || + (excluded.baseYears.get(base) ?? []).some((year) => + titleYearsCompatible(candidate.year, year) + ) + ); +} + +/** + * Identity of one load: the seed set, the watched/favorited exclusion + * index, and the imported-playlist set. Including the exclusions means + * favoriting a recommended title (or new watch history that does not + * change the top seeds) still invalidates the latch and re-filters the + * rail; including the catalog means imports and deletions re-run the + * matching. + */ +export function buildLoadKey( + seeds: readonly DashboardTmdbLookupItem[], + excluded: ExclusionIndex, + catalogKey: string +): string { + const serializeYears = ( + entries: ReadonlyMap + ): string[] => + [...entries] + .map( + ([key, years]) => + `${key}=${[...years] + .map((year) => year ?? '?') + .sort() + .join('/')}` + ) + .sort(); + + const exclusionKey = [ + ...serializeYears(excluded.exact), + ...serializeYears(excluded.baseYears), + ].join('|'); + return `${catalogKey}@@${seeds + .map(dashboardTmdbLookupKey) + .join('||')}##${exclusionKey}`; +} + +export function toCandidates( + results: readonly TmdbSearchResult[], + mediaType: 'movie' | 'tv', + seedTitle: string +): RecommendationCandidate[] { + return results + .map((result) => { + const title = result.title ?? result.name ?? ''; + const original = + result.original_title ?? result.original_name ?? ''; + const rating = + (result.vote_count ?? 0) > 0 && result.vote_average + ? result.vote_average.toFixed(1) + : null; + return { + tmdbId: result.id, + mediaType, + title, + originalTitle: + original !== '' && original !== title ? original : null, + year: extractYear(result.release_date ?? result.first_air_date), + posterUrl: tmdbPosterUrl(result.poster_path), + rating, + seedTitle, + }; + }) + .filter((entry) => entry.tmdbId > 0 && entry.title !== ''); +} diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.ts new file mode 100644 index 000000000..a95f7bfb0 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.ts @@ -0,0 +1,123 @@ +import { extractYear } from '@iptvnator/services'; +import { + PortalActivityItem, + TmdbMediaType, + extractStalkerItemTmdbHints, +} from '@iptvnator/shared/interfaces'; + +/** Everything a dashboard TMDB lookup reads off an activity row */ +export type DashboardTmdbLookupItem = Pick< + PortalActivityItem, + 'title' | 'type' | 'stalker_item' | 'source' +>; + +/** + * One lookup attempt: a media type plus the query to run under it. The + * TMDB id belongs to exactly one media type, so a second attempt under the + * other one must drop it — `/movie/` resolves to an unrelated film + * whose details would then be rendered as this item's. + */ +export interface DashboardTmdbAttempt { + readonly mediaType: TmdbMediaType; + readonly title: string; + readonly originalTitle?: string; + readonly tmdbId?: number; + readonly year: number | null; +} + +/** + * Ordered lookup attempts for one activity row. Stalker rows carry the + * facts of the detail view's own enrichment, so they lead with those; + * everything else can only offer the display title. + * + * The query is built to match what the detail view searched with, not just + * what the card displays. A title alone is weaker identity than the detail + * page had: without a year `pickConfidentMatch` falls back to requiring a + * single exact title match, which common titles never satisfy, and the miss + * is cached under a lookup key the detail view's hit can never be found at. + * + * A `'movie'` verdict gets a second attempt under `'tv'`, because for a + * Stalker row `'movie'` is what everything falls back to when nothing + * says otherwise — an embedded-VOD ("vclub") series is a `'movie'` + * activity row, and a lazily-loaded Ministra item can be stored before + * its series marker is known. The retry drops the id (`/movie/` + * resolves to an unrelated film), so a wrong default costs one + * negatively-cached search rather than another title's metadata. + * + * An Xtream row gets NO such retry: its type comes from the imported + * catalog, which files movies and series separately, so `'movie'` there + * is evidence rather than a default. Retrying would let a same-titled + * show answer for a film — the mirror of the `'tv'` rule below. + * + * `'tv'` gets no such retry. It is only ever reached on positive + * evidence — a series category, an `is_series` flag, or a non-empty + * episode array — and retrying it as a movie would trade that evidence + * for a same-title film: the gate cannot tell an adaptation sharing its + * show's name and year from the show itself. + */ +export function buildDashboardTmdbAttempts( + item: DashboardTmdbLookupItem +): DashboardTmdbAttempt[] { + if (item.type !== 'movie' && item.type !== 'series') { + return []; + } + + const hints = item.stalker_item + ? extractStalkerItemTmdbHints(item.stalker_item) + : null; + const title = hints?.title ?? item.title; + // A stalker entry with no usable name falls back to the placeholder + // `extractStalkerItemTitle` produces. The detail view refuses to + // enrich those, and so must this — "Unknown" is itself a real film + // title (2011), so searching for it attaches another movie's data. + if (!title || (hints !== null && !hints.title && title === 'Unknown')) { + return []; + } + + const mediaType: TmdbMediaType = + hints?.mediaType ?? (item.type === 'series' ? 'tv' : 'movie'); + const year = hints?.year ?? extractYear(null, title); + const primary: DashboardTmdbAttempt = { + mediaType, + title, + originalTitle: hints?.originalTitle, + tmdbId: hints?.tmdbId, + year, + }; + + // The retry is for a `'movie'` that nothing confirmed. Two things + // confirm one: the Xtream catalog, which files movies and series + // apart, and a stored Stalker `tmdb_id`, which is never a provider + // claim — its only source is a match this app already gated, under + // this very media type. Retrying either would let a same-titled show + // answer for a film. + const confirmedMovie = item.source === 'xtream' || primary.tmdbId != null; + return mediaType === 'movie' && !confirmedMovie + ? [primary, { ...primary, mediaType: 'tv', tmdbId: undefined }] + : [primary]; +} + +/** + * Identity of the lookup for an item — memo keys, and the staleness guard + * callers compare against while a request is in flight. + * + * The WHOLE attempt sequence is the identity, not just the primary one: + * two rows can share a title, year and id yet differ in whether a `tv` + * fallback follows, and callers cache results under this key. The hero's + * root-level memo would otherwise hand a Stalker row's `tv` answer to an + * Xtream movie, and `selectSeeds()` would collapse two seeds that do not + * perform the same lookup. + */ +export function dashboardTmdbLookupKey(item: DashboardTmdbLookupItem): string { + const attempts = buildDashboardTmdbAttempts(item); + const [primary] = attempts; + return primary + ? [ + attempts.map((attempt) => attempt.mediaType).join('>'), + primary.title, + primary.originalTitle ?? '', + primary.year ?? '', + primary.tmdbId ?? '', + ].join('|') + : `${item.type}:${item.title}`; +} diff --git a/libs/workspace/dashboard/feature/README.md b/libs/workspace/dashboard/feature/README.md index bc9d0083c..8c8fe5acd 100644 --- a/libs/workspace/dashboard/feature/README.md +++ b/libs/workspace/dashboard/feature/README.md @@ -27,6 +27,15 @@ rails also require the underlying data slice to have at least one item. cover cards. - `recentSources` shows recently used playlist/source entries. - `xtreamRecentlyAdded` shows recently added Xtream catalog items. +- `tmdbRecommendations` shows "Because you watched X" — TMDB + recommendations seeded from recently watched movies/series, kept to + titles that exist in an imported Xtream library. Needs the TMDB opt-in + and the Electron DB worker (hidden in the PWA), and hides itself below + five matched cards. Data: + `DashboardRecommendationsService` in `workspace/dashboard/data-access`. +- `tmdbTrending` shows TMDB's weekly trending titles, matched against the + imported Xtream libraries. Same TMDB/Electron gating; unmatched cards + open the global search prefilled. Data: `DashboardTrendingService`. ## Settings diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts index 1cf416a33..e750713da 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts @@ -103,6 +103,36 @@ describe('DashboardHeroTmdbService', () => { expect(extras?.genres).toEqual(['Drama', 'Comedy']); }); + it('does not retry a catalog-classified Xtream movie as TV', async () => { + // The Xtream catalog files movies and series apart, so 'movie' + // there is evidence rather than the ambiguous default. + enrichMovie.mockResolvedValue(null); + const service = createService(); + + await service.getExtras({ + title: 'Fargo', + type: 'movie', + source: 'xtream', + } as DashboardHeroTmdbItem); + + expect(enrichTv).not.toHaveBeenCalled(); + }); + + it('keys the lookup by the whole attempt sequence', async () => { + // Same title/type/year, different retry policy: one falls back to + // tv, the other does not, so the memo must not treat them as one. + const service = createService(); + const ambiguous = { title: 'Fargo', type: 'movie' } as const; + const catalogClassified = { + ...ambiguous, + source: 'xtream', + } as DashboardHeroTmdbItem; + + expect(service.keyFor(ambiguous)).not.toBe( + service.keyFor(catalogClassified) + ); + }); + it('does not retry a series-typed item as a movie', async () => { // 'movie' is the answer every row falls back to, so it earns a // retry; 'tv' is only reached on positive evidence and must not be @@ -153,9 +183,11 @@ describe('DashboardHeroTmdbService', () => { expect(extras?.backdropUrl).toContain('/serial.jpg'); }); - it('never carries the id into the other media type', async () => { - // /movie/ resolves to an unrelated film whose details - // would then be shown as this item's — so the retry drops it. + it('uses a stored id for the lookup and stops retrying once it has one', async () => { + // A Stalker `info.tmdb_id` is never a provider claim — its only + // source is a match this app already gated, under this very + // media type — so 'movie' is no longer the ambiguous default + // the TV retry exists for. enrichMovie.mockResolvedValue(null); const service = createService(); @@ -164,8 +196,28 @@ describe('DashboardHeroTmdbService', () => { expect(enrichMovie).toHaveBeenCalledWith( expect.objectContaining({ tmdbId: 603, year: 1999 }) ); + expect(enrichTv).not.toHaveBeenCalled(); + }); + + it('retries an id-less entry as TV, carrying no id across', async () => { + // /movie/ resolves to an unrelated film, so the retry + // never carries one — the case that can still reach it is an + // entry whose media type nothing has confirmed. + enrichMovie.mockResolvedValue(null); + const service = createService(); + + await service.getExtras({ + title: 'Холод', + type: 'movie', + stalker_item: { + id: '77', + category_id: 'vclub', + info: { name: 'Холод', releasedate: '2026' }, + }, + } as unknown as DashboardHeroTmdbItem); + expect(enrichTv).toHaveBeenCalledWith( - expect.objectContaining({ tmdbId: undefined, year: 1999 }) + expect.objectContaining({ tmdbId: undefined, year: 2026 }) ); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts index 8d7e304cc..d01d9af63 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts @@ -1,14 +1,11 @@ import { Injectable, inject } from '@angular/core'; +import { TmdbEnrichmentService, tmdbBackdropUrl } from '@iptvnator/services'; import { - TmdbEnrichmentService, - extractYear, - tmdbBackdropUrl, -} from '@iptvnator/services'; -import { - TmdbMediaType, - extractStalkerItemTmdbHints, -} from '@iptvnator/shared/interfaces'; -import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access'; + DashboardTmdbAttempt, + DashboardTmdbLookupItem, + buildDashboardTmdbAttempts, + dashboardTmdbLookupKey, +} from '@iptvnator/workspace/dashboard/data-access'; /** TMDB extras for the dashboard hero, patched in after first paint */ export interface DashboardHeroTmdbExtras { @@ -18,24 +15,7 @@ export interface DashboardHeroTmdbExtras { } /** Everything the hero lookup reads off an activity row */ -export type DashboardHeroTmdbItem = Pick< - GlobalRecentItem, - 'title' | 'type' | 'stalker_item' ->; - -/** - * One lookup attempt: a media type plus the query to run under it. The - * TMDB id belongs to exactly one media type, so a second attempt under the - * other one must drop it — `/movie/` resolves to an unrelated film - * whose details would then be rendered as this item's. - */ -interface HeroTmdbAttempt { - readonly mediaType: TmdbMediaType; - readonly title: string; - readonly originalTitle?: string; - readonly tmdbId?: number; - readonly year: number | null; -} +export type DashboardHeroTmdbItem = DashboardTmdbLookupItem; const MAX_HERO_GENRES = 2; @@ -46,11 +26,9 @@ const MAX_HERO_GENRES = 2; * per lookup identity for the session — dashboard revisits skip the IPC * round-trip. * - * The query is built to match what the detail view searched with, not just - * what the card displays. A title alone is weaker identity than the detail - * page had: without a year `pickConfidentMatch` falls back to requiring a - * single exact title match, which common titles never satisfy, and the miss - * is cached under a lookup key the detail view's hit can never be found at. + * The lookup attempts and their identity key are shared with the + * recommendations rail (`dashboard-tmdb-lookup.util.ts`), so the two can + * never disagree about how an activity row resolves to TMDB. */ @Injectable({ providedIn: 'root' }) export class DashboardHeroTmdbService { @@ -67,26 +45,16 @@ export class DashboardHeroTmdbService { /** * Identity of the lookup for an item — the memo key, and the staleness - * guard callers compare against while a request is in flight. Derived - * here so the two can never disagree about what "the same hero" means. + * guard callers compare against while a request is in flight. */ keyFor(item: DashboardHeroTmdbItem): string { - const [primary] = this.buildAttempts(item); - return primary - ? [ - primary.mediaType, - primary.title, - primary.originalTitle ?? '', - primary.year ?? '', - primary.tmdbId ?? '', - ].join('|') - : `${item.type}:${item.title}`; + return dashboardTmdbLookupKey(item); } getExtras( item: DashboardHeroTmdbItem ): Promise { - const attempts = this.buildAttempts(item); + const attempts = buildDashboardTmdbAttempts(item); if (!this.enrichment.isEnabled() || attempts.length === 0) { return Promise.resolve(null); } @@ -102,60 +70,8 @@ export class DashboardHeroTmdbService { return pending; } - /** - * Ordered lookup attempts for one activity row. Stalker rows carry the - * facts of the detail view's own enrichment, so they lead with those; - * everything else can only offer the display title. - * - * A `'movie'` verdict gets a second attempt under `'tv'`, because - * `'movie'` is what every row falls back to when nothing says otherwise - * — an embedded-VOD ("vclub") series is a `'movie'` activity row, and a - * lazily-loaded Ministra item can be stored before its series marker is - * known. The retry drops the id (`/movie/` resolves to an - * unrelated film), so a wrong default costs one negatively-cached search - * rather than another title's metadata. - * - * `'tv'` gets no such retry. It is only ever reached on positive - * evidence — a series category, an `is_series` flag, or a non-empty - * episode array — and retrying it as a movie would trade that evidence - * for a same-title film: the gate cannot tell an adaptation sharing its - * show's name and year from the show itself. - */ - private buildAttempts(item: DashboardHeroTmdbItem): HeroTmdbAttempt[] { - if (item.type !== 'movie' && item.type !== 'series') { - return []; - } - - const hints = item.stalker_item - ? extractStalkerItemTmdbHints(item.stalker_item) - : null; - const title = hints?.title ?? item.title; - // A stalker entry with no usable name falls back to the placeholder - // `extractStalkerItemTitle` produces. The detail view refuses to - // enrich those, and so must this — "Unknown" is itself a real film - // title (2011), so searching for it attaches another movie's data. - if (!title || (hints !== null && !hints.title && title === 'Unknown')) { - return []; - } - - const mediaType: TmdbMediaType = - hints?.mediaType ?? (item.type === 'series' ? 'tv' : 'movie'); - const year = hints?.year ?? extractYear(null, title); - const primary: HeroTmdbAttempt = { - mediaType, - title, - originalTitle: hints?.originalTitle, - tmdbId: hints?.tmdbId, - year, - }; - - return mediaType === 'movie' - ? [primary, { ...primary, mediaType: 'tv', tmdbId: undefined }] - : [primary]; - } - private async loadExtras( - attempts: readonly HeroTmdbAttempt[] + attempts: readonly DashboardTmdbAttempt[] ): Promise { try { for (const attempt of attempts) { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index e24182c67..8d725573e 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -355,6 +355,35 @@ } + + @if ( + dashboardRails().tmdbRecommendations && + recommendationCards().length > 0 + ) { + + } @else if ( + dashboardRails().tmdbRecommendations && + recommendationsService.loading() + ) { + + } + @if (dashboardRails().tmdbTrending && trendingCards().length > 0) { this.data.playlists().length > 0); readonly ready = this.data.dashboardReady; @@ -343,6 +346,28 @@ export class WorkspaceDashboardRailsComponent { .map((item) => this.toTrendingCard(item)); }); + readonly recommendationCards = computed(() => { + if (!this.recommendationsService.isAvailable) { + return []; + } + return this.recommendationsService + .items() + .map((item) => this.toRecommendationCard(item)); + }); + + readonly recommendationsRailLabel = computed(() => { + this.languageTick(); + const seeds = this.recommendationsService.seedTitles(); + // A single contributing seed earns the honest Netflix-style header; + // a mixed rail falls back to the generic one. + return seeds.length === 1 + ? this.translate.instant( + 'WORKSPACE.DASHBOARD.TMDB_RECOMMENDED_BECAUSE', + { title: seeds[0] } + ) + : this.t('WORKSPACE.DASHBOARD.TMDB_RECOMMENDED'); + }); + // Minute heartbeat for the expiry badges: resolveSourceExpiryBadge reads // the wall clock, so without a reactive tick a dashboard left open would // never cross a day-countdown or expiration boundary. interval() emits @@ -455,6 +480,27 @@ export class WorkspaceDashboardRailsComponent { } untracked(() => void this.trendingService.load()); }); + + // Recommendations rail: same gating as trending, plus tracked + // reads of the seed source, the favorites (both feed the + // exclusion set) and the playlist set (feeds the catalog key) so + // a newly watched/favorited title or an imported/deleted playlist + // re-runs the load — the service keys loads by seed + exclusion + + // catalog set and skips no-ops. + effect(() => { + if ( + !this.dashboardRails().tmdbRecommendations || + !this.data.globalFavoritesLoaded() + ) { + return; + } + this.data.globalRecentVodItems(); + this.data.globalFavoriteItems(); + this.data.playlists(); + // Language feeds the service's load key (localized payloads) + this.languageTick(); + untracked(() => void this.recommendationsService.load()); + }); } onAddPlaylist(type?: WorkspacePlaylistType): void { @@ -630,6 +676,33 @@ export class WorkspaceDashboardRailsComponent { }; } + private toRecommendationCard( + item: DashboardRecommendationItem + ): DashboardRailCard { + const subtitle = [ + item.year !== null ? String(item.year) : null, + item.rating ? `★ ${item.rating}` : null, + item.match.playlistName, + ] + .filter((value): value is string => Boolean(value)) + .join(' · '); + return { + id: `rec-${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: [ + '/workspace/xtreams', + item.match.playlistId, + item.match.type === 'movie' ? 'vod' : 'series', + String(item.match.categoryId), + String(item.match.xtreamId), + ], + }; + } + private buildSourceExpiryBadge( playlistId: string ): DashboardRailCard['expiryBadge'] {