From 4bcd4bd39035088bcdec99c086867b99db2fb1cd Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:39:43 +0200 Subject: [PATCH] feat(dashboard): add TMDB "Because you watched" recommendations rail (#1419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): add TMDB "Because you watched" recommendations rail TMDB has no account-free "for you" endpoint, so the rail seeds per-title recommendations from up to 3 recently watched movies/series. Seeds resolve through the enrichment facade via a shared lookup-attempt builder (extracted from the hero service), and recommendations already ride in every cached details payload, so watched seeds cost zero network. Per-seed lists are interleaved round-robin, deduplicated by id and normalized title, stripped of watched/favorited titles, and matched against imported libraries with one batched DB_MATCH_TITLES request; only year-compatible matches render and fewer than 5 cards hides the rail. Loads are keyed by the seed set, and a load where no seed resolved retries instead of latching. The header names the seed ("Because you watched X") when exactly one seed contributed, else falls back to the generic "Recommended for you". New dashboardRails.tmdbRecommendations toggle (default on) in Settings -> Dashboard; 4 new i18n keys translated across all 19 locales. Co-Authored-By: Claude Fable 5 * fix(dashboard): harden recommendations rail reload semantics Address Codex review findings: the load latch is now keyed by the seed set PLUS the watched/favorited exclusion set, so favoriting a recommended title re-filters the rail instead of being ignored by the seed-only memo; an emptied watch history clears the root-provided service's items and seed titles instead of leaving a stale rail; and a load requested while one is in flight is queued and re-run afterwards, so a mid-flight history change cannot commit results for an obsolete seed set. The dashboard effect now also tracks favorites. Three regression tests added. Co-Authored-By: Claude Fable 5 * fix(dashboard): catalog-aware invalidation, no empty latch, original-title aliases Address Codex round-2 findings: the load key now includes the imported-playlist id set, so importing or deleting a playlist re-runs the catalog matching instead of leaving dead links or hiding fresh matches; a below-threshold (or transiently failed) match result hides the rail WITHOUT latching, mirroring the trending rail's retry-on-empty semantics, since matchTitles maps worker failures to an empty list; and matching plus watched/favorited exclusion now work through both the localized TMDB title and the original-title alias, so a catalog named in the original language still matches while cards keep displaying the localized form. Regression tests added for all three. Co-Authored-By: Claude Fable 5 * fix(dashboard): reset latch on hide, alias-year fallback, language-keyed loads Address Codex round-3 findings: hiding the rail below the match threshold now also resets the saved load key, so returning to a previously successful input set (un-favoriting, restoring a playlist) reloads instead of dying on the equality guard; alias matching picks the first alias whose match is also year-compatible, so a same-named different-year row hit by the localized title no longer vetoes the correct original-title match; and the load key now includes the effective TMDB language (exposed on the enrichment facade), so switching the app language re-localizes the cards instead of keeping the previous language all session. Regression tests added for all three. Co-Authored-By: Claude Fable 5 * fix(dashboard): two-tier watched-title exclusion, drop ES2019 flatMap Address the Codex round-4 finding: a provider stores whatever the panel named the file, so a watched "Inception 2010" never matched TMDB's canonical "Inception" by exact key. Exclusion now runs on two tiers — exact normalized title plus a year-gated base tier — so the year-suffixed shape is caught while a stored "Blade Runner 2049" still cannot swallow the 1982 film. An unknown year on either side counts as agreeing, since re-recommending something already watched is the worse failure. Also replaces the alias query builder's flatMap with a loop: the web app compiles this lib against lib: es2018, where Array.prototype.flatMap does not exist, which broke the web build and every job downstream of it. Both exclusion tiers are pinned by mutation-verified regression tests. Co-Authored-By: Claude Fable 5 * fix(dashboard): index recommendation exclusions the way TMDB looks them up Address Codex round-5 findings. The watched/favorited exclusion index is now built through the same lookup-attempt builder the seeds and the hero use, so an activity row is indexed under the media type the detail view enriched with rather than its routing verdict — a Stalker embedded-VOD series routes as 'movie' but is a show to TMDB, so its recommendation looked up series: and sailed past a movie:-only entry — and under its stored original-language title (info.o_name), which a translated recommendation shares no key with. Only the builder's PRIMARY attempt is indexed: the second is a fallback guess, and indexing it would let a watched film exclude the same-named show. Adds Electron E2E for the new setting: the toggle now appears in the disabled-when-dashboard-off assertion (with the trending toggle, which was also missing), plus a restart-persistence test. Rail rendering stays unit-covered — it needs the TMDB opt-in, live TMDB data and catalog matches, which would make an E2E network-dependent and flaky. All three new unit tests are mutation-verified, including one that was passing vacuously before this round. Co-Authored-By: Claude Fable 5 * fix(dashboard): keep every catalog row until the year gate has chosen Address the Codex round-6 finding: buildTitleMatchIndex 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 the worker returned first and a 2021 recommendation then fails the year check with the right row already discarded. The rail now groups the rows per key itself and lets the year gate pick, still preferring an exact-title match over a year-stripped one so the shared helper's precedence is preserved. Mutation-verified regression test. The trending rail shares the same collapse-then-check shape and is unaffected by this PR; flagged separately as a follow-up. Co-Authored-By: Claude Fable 5 * fix(dashboard): survive a failed refresh, document the new rail Address Codex round-7 findings. A refresh that cannot reach TMDB no longer leaves the rail untouched, but it does not blank it either: a failed request is not a verdict that there is nothing to recommend, and removing still-valid cards is the worse answer for an offline user. What the failure cannot excuse is a card the user has since watched or favorited, so the retained cards are re-filtered against the fresh exclusion index and the rail hides if too few survive. The key stays unlatched, so the next visit still retries. Also documents the rail in the two canonical dashboard docs I missed: the surface diagram and render rules in docs/architecture/workspace-dashboard.md and the rail list in the feature README. Both had also never mentioned the sibling trending rail, so that gap is closed in the same pass. Co-Authored-By: Claude Fable 5 * fix(dashboard): year-aware exclusions, remake-safe dedupe, key reset Address Codex round-8 findings. The exclusion index now records each row's release year (Stalker's info.releasedate, else a year read off the title) with every key, and both tiers gate on it, so a watched 1954 "Godzilla" no longer excludes the 2014 one. A row that states no year records null and keeps excluding unconditionally, so the conservative behaviour survives where nothing is known. Candidate dedupe is by TMDB id only; title collisions are resolved after matching, by the catalog row a candidate resolved to. Same-titled remakes ("Dune" 1984 and 2021) are different films and must both reach the matcher — collapsing them beforehand let whichever arrived first fail the year gate on behalf of the one the library actually holds — while two candidates landing on one row would render as duplicate cards. The offline re-filter now clears the saved load key, so restoring those exact inputs (un-favoriting the title) rebuilds the rail instead of hitting the equality guard. Splits the pure helpers and data shapes into dashboard-recommendations.util.ts: the service had crossed the 400-line production limit. All three fixes are mutation-verified, including one test that only became real after the mutation showed it passing on the wrong ordering. Co-Authored-By: Claude Fable 5 * fix(dashboard): do not latch a partially resolved seed set Address the Codex round-9 finding: when several seeds load and only some resolve, latching marked the whole set complete, so a seed that failed transiently lost its recommendations for the rest of the session. The load now latches only once every seed has answered. A seed with no TMDB match never resolves either, so that user's rail re-runs on each dashboard visit. That is bounded work — the enrichment misses are cached and the catalog match is one batched worker call — and it matches the rail's existing policy of not latching on uncertainty. Mutation-verified regression test, plus one pinning that a fully resolved set still latches. Co-Authored-By: Claude Fable 5 * fix(dashboard): trust only stated years on the exact exclusion tier Address Codex round-10 findings. The first is a regression I introduced last round: recording a title-inferred year with the exact exclusion key meant a watched "Blade Runner 2049" carried year 2049, disagreed with TMDB's actual 2017, and stopped excluding the very film the user had just watched. The exact tier now gates only on a year the row STATES in a metadata field (Stalker's info.releasedate) — the rule releaseTagYear already documents: on a whole-title match a trailing number belongs to the name and nothing can settle it. The base tier keeps its stripped trailing year, which is a suffix by construction, so the Godzilla 1954/2014 case still holds. The offline re-filter also drops cards whose playlist has been deleted. That path is the only one that can reach retained cards without the catalog key rebuilding the rail, so those cards would otherwise navigate to a dead route. Both fixes are mutation-verified. Co-Authored-By: Claude Fable 5 * fix(dashboard): resolved media type replaces the routing one; prefer year-tagged rows Address Codex round-11 findings. The exclusion index no longer indexes an activity row under BOTH its routing type and its resolved media type. A Stalker embedded-VOD series routes as 'movie' on positive series evidence, so keeping that key made a watched show exclude an unrelated film of the same name — and, with no release date to gate on, unconditionally. The resolved type now replaces the routing one; a row the builder cannot classify keeps its routing type, which is then the only thing known. Catalog matching now prefers a row whose stripped year IS the candidate's over an untagged one: an untagged "Dune" row could be either cut, so linking a 2021 recommendation to it while "Dune 2021" also exists throws away the better evidence. Untagged rows stay next in precedence, which is also the only tier reachable when the candidate's year is unknown. Both mutation-verified. Co-Authored-By: Claude Fable 5 * fix(dashboard): no TV retry for catalog-classified Xtream rows Address the Codex round-12 finding: the movie -> tv lookup retry exists because a Stalker embedded-VOD series is stored as a 'movie' activity row, but an Xtream row's type comes from a catalog that files movies and series apart, so there 'movie' is evidence rather than a default. The retry let a same-titled show answer for a film — the mirror of the existing rule that a 'tv' verdict never retries as 'movie'. The lookup item type had dropped the `source` field that distinguishes them; restoring it is enough to gate the retry. This also tightens the hero rail, which shares the builder. Mutation-verified. Co-Authored-By: Claude Fable 5 * fix(dashboard): confirmed movies skip the TV retry; key by the whole attempt chain Address Codex round-13 findings, both consequences of last round's change. A stored Stalker `info.tmdb_id` is never a provider claim — the contract says its only source is a match this app already gated, under that very media type — so such a row's 'movie' verdict is no longer the ambiguous default the TV retry exists for. Retrying it let a same-titled show answer for a film whenever the movie lookup transiently returned null. The retry now runs only for rows nothing has confirmed. The lookup key is now the whole attempt sequence rather than the primary attempt alone: two rows can share title, year and id yet differ in whether a TV fallback follows, and callers cache by this key — the hero's root-level memo would otherwise serve a Stalker row's TV answer as an Xtream movie's metadata, and selectSeeds() would collapse two seeds that do not perform the same lookup. Both mutation-verified. One existing hero test asserted the retry for a fixture that carries a stored id; it now pins the confirmed-identity behaviour instead, with a separate test for the id-less retry it used to cover. Co-Authored-By: Claude Fable 5 * fix(dashboard): rank catalog matches by year evidence across aliases Address the Codex round-14 finding: match selection returned as soon as any alias had a compatible row, so an untagged row under the localized title beat a row the original-title alias found carrying the candidate's own year — the wrong remake when both cuts exist. Compatible rows from every alias now form one pool ranked by evidence, with alias order kept only as the tiebreaker inside a tier. The nested loop collapses into a single pass in the process. Mutation-verified. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changes/dashboard-tmdb-recommendations.md | 10 + CLAUDE.md | 2 +- apps/electron-backend-e2e/src/settings.e2e.ts | 38 + .../settings-dashboard-section.component.html | 23 + .../src/app/settings/settings-form.utils.ts | 2 + .../test-stubs/settings-test-harness.stub.ts | 1 + apps/web/src/assets/i18n/ar.json | 4 + apps/web/src/assets/i18n/ary.json | 4 + apps/web/src/assets/i18n/by.json | 4 + apps/web/src/assets/i18n/de.json | 4 + apps/web/src/assets/i18n/el.json | 4 + apps/web/src/assets/i18n/en.json | 4 + apps/web/src/assets/i18n/es.json | 4 + apps/web/src/assets/i18n/fr.json | 4 + apps/web/src/assets/i18n/hu.json | 4 + apps/web/src/assets/i18n/it.json | 4 + apps/web/src/assets/i18n/ja.json | 4 + apps/web/src/assets/i18n/ko.json | 4 + apps/web/src/assets/i18n/nl.json | 4 + apps/web/src/assets/i18n/pl.json | 4 + apps/web/src/assets/i18n/pt.json | 4 + apps/web/src/assets/i18n/ru.json | 4 + apps/web/src/assets/i18n/tr.json | 4 + apps/web/src/assets/i18n/zh.json | 4 + apps/web/src/assets/i18n/zhtw.json | 4 + docs/architecture/tmdb-metadata-enrichment.md | 98 +- docs/architecture/workspace-dashboard.md | 17 +- .../src/lib/settings-store.service.spec.ts | 1 + .../src/lib/tmdb/tmdb-enrichment.service.ts | 9 + .../interfaces/src/lib/settings.interface.ts | 6 + .../dashboard/data-access/src/index.ts | 3 + .../dashboard-recommendations.service.spec.ts | 1002 +++++++++++++++++ .../lib/dashboard-recommendations.service.ts | 451 ++++++++ .../src/lib/dashboard-recommendations.util.ts | 272 +++++ .../src/lib/dashboard-tmdb-lookup.util.ts | 123 ++ libs/workspace/dashboard/feature/README.md | 9 + .../rails/dashboard-hero-tmdb.service.spec.ts | 60 +- .../lib/rails/dashboard-hero-tmdb.service.ts | 112 +- .../workspace-dashboard-rails.component.html | 29 + .../workspace-dashboard-rails.component.ts | 73 ++ 40 files changed, 2310 insertions(+), 107 deletions(-) create mode 100644 .changes/dashboard-tmdb-recommendations.md create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.ts diff --git a/.changes/dashboard-tmdb-recommendations.md b/.changes/dashboard-tmdb-recommendations.md new file mode 100644 index 000000000..cc8bc69a4 --- /dev/null +++ b/.changes/dashboard-tmdb-recommendations.md @@ -0,0 +1,10 @@ +--- +type: feature +area: dashboard +--- + +The dashboard gains a "Because you watched" rail: TMDB recommendations seeded +from your recently watched movies and series, showing only titles that actually +exist in your imported libraries. It appears once at least five matches are +found, can be turned off under Settings → Dashboard, and requires the TMDB +metadata opt-in (desktop app only). diff --git a/CLAUDE.md b/CLAUDE.md index caf3e1f81..6d96f2fa6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1258,7 +1258,7 @@ engine` (restart required) or - Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works - "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation - Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`) -- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; a `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url` +- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle), a "Because you watched" recommendations rail (`dashboardRails.tmdbRecommendations` toggle; TMDB has no account-free "for you" endpoint, so `DashboardRecommendationsService` seeds per-title `recommendations` — already riding in every cached details payload — from up to 3 recently watched movies/series via the shared `dashboard-tmdb-lookup.util.ts` attempt builder, interleaves them, dedupes by TMDB id (title collisions are resolved after matching, by the catalog row, so same-titled remakes both reach the matcher), drops watched/favorited titles through a year-gated exclusion index built by the same lookup-attempt builder (so a Stalker embedded-VOD series indexes under `series:` despite routing as `movie`, and its stored `o_name` alias counts too; only the PRIMARY attempt is indexed, or a watched film would swallow the same-named show) on two title tiers (exact normalized title plus a year-gated base tier so a stored "Inception 2010" excludes TMDB's "Inception" while "Blade Runner 2049" does not swallow the 1982 film), keeps only year-compatible `DB_MATCH_TITLES` matches — matching/exclusion run through both the localized title and the TMDB original-title alias, and a year-incompatible first alias falls through to the other — and hides the rail below 5 cards while resetting the latch; successful loads are keyed by TMDB language + seed set + watched/favorited exclusion set + imported-playlist ids, an emptied history clears the rail, a mid-flight load request is queued, and a no-seed-resolved load retries instead of latching) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; an unconfirmed Stalker `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`; a confirmed `movie` gets none either, and is confirmed by an Xtream `source` (that catalog files movies and series apart) or by a stored Stalker `info.tmdb_id` (never a provider claim, only a match this app already gated). The lookup key is the WHOLE attempt sequence, since two rows can share title/year/id yet differ in whether a `tv` fallback follows, and callers memoize by it. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url` - Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday` - Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) - Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts` diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 3e1d623de..f05e3192e 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -597,6 +597,8 @@ test.describe('Electron Settings', () => { 'toggle-dashboard-rail-favorite-movies-and-series', 'toggle-dashboard-rail-recent-sources', 'toggle-dashboard-rail-xtream-recently-added', + 'toggle-dashboard-rail-tmdb-trending', + 'toggle-dashboard-rail-tmdb-recommendations', ]) { await expect( firstLaunch.mainWindow @@ -675,6 +677,42 @@ test.describe('Electron Settings', () => { } }); + // The TMDB recommendations rail itself needs the TMDB opt-in, live + // TMDB data and catalog matches, so its rendering is covered by unit + // tests rather than here. What IS deterministic — and what a form + // regression would silently break — is that its toggle persists. + test('@settings @dashboard @electron persists the TMDB recommendations rail toggle across a restart', async ({ + dataDir, + }) => { + let app = await launchElectronApp(dataDir); + + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'dashboard'); + + const toggle = app.mainWindow + .getByTestId('toggle-dashboard-rail-tmdb-recommendations') + .locator('input[type="checkbox"]'); + await expect(toggle).toBeChecked(); + await toggle.uncheck(); + await saveSettings(app.mainWindow); + } finally { + app = await restartElectronApp(app, dataDir); + } + + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'dashboard'); + await expect( + app.mainWindow + .getByTestId('toggle-dashboard-rail-tmdb-recommendations') + .locator('input[type="checkbox"]') + ).not.toBeChecked(); + } finally { + await closeElectronApp(app); + } + }); + test('restores the last section-level view across restart when configured', async ({ dataDir, }) => { diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.html b/apps/web/src/app/settings/settings-dashboard-section.component.html index 1b35089af..5c5657c4f 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.html +++ b/apps/web/src/app/settings/settings-dashboard-section.component.html @@ -177,5 +177,28 @@ > + +
+
+

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

+

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

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