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 @@
>
+
+
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()
+ ) {
+
+
+
+ @for (slot of skeletonSlots; track slot) {
+
+ }
+
+
+ }
+
@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'] {