feat(dashboard): add TMDB "Because you watched" recommendations rail (#1419)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-12 07:39:43 +02:00
1 parent dded17010d
commit 4bcd4bd390
40 files changed
+2310 -107

No files matched your search

@@ -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).
+1 -1
View File
@@ -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`
@@ -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,
}) => {
@@ -177,5 +177,28 @@
></mat-checkbox>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS'
| translate
}}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="tmdbRecommendations"
data-test-id="toggle-dashboard-rail-tmdb-recommendations"
></mat-checkbox>
</div>
</div>
</div>
</section>
@@ -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,
@@ -79,6 +79,7 @@ export const DEFAULT_DASHBOARD_RAILS = {
recentSources: true,
xtreamRecentlyAdded: true,
tmdbTrending: true,
tmdbRecommendations: true,
};
export const DEFAULT_SETTINGS = {
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+95 -3
View File
@@ -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
+16 -1
View File
@@ -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.
@@ -24,6 +24,7 @@ const expectedDashboardRails = (
recentSources: true,
xtreamRecentlyAdded: true,
tmdbTrending: true,
tmdbRecommendations: true,
...overrides,
});
@@ -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<TmdbMovieDetails | null> {
@@ -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<
@@ -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';
@@ -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<DashboardRecommendationItem[]>([]);
/** Seeds that contributed at least one visible card, most recent first */
readonly seedTitles = signal<readonly string[]>([]);
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<void> {
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<string>();
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<SeedRecommendations> {
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<string>();
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<string, (number | null)[]>();
const baseYears = new Map<string, number[]>();
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<DashboardRecommendationItem[]> {
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<string>();
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;
}
}
@@ -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<DashboardRecommendationItem, 'match'>;
/**
* 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<string, unknown> } | 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<string, readonly (number | null)[]>;
/**
* `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<string, readonly number[]>;
}
/** 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<string, CatalogTitleMatch[]> {
const grouped = new Map<string, CatalogTitleMatch[]>();
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<string, CatalogTitleMatch[]>
): 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, readonly (number | null)[]>
): 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 !== '');
}
@@ -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/<tv id>` 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/<tv id>`
* 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}`;
}
@@ -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
@@ -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/<tv id> 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/<tv id> 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 })
);
});
@@ -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/<tv id>` 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<DashboardHeroTmdbExtras | null> {
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/<tv id>` 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<DashboardHeroTmdbExtras | null> {
try {
for (const attempt of attempts) {
@@ -355,6 +355,35 @@
</section>
}
<!-- TMDB recommendations rail ("Because you watched"; opt-in; Electron only) -->
@if (
dashboardRails().tmdbRecommendations &&
recommendationCards().length > 0
) {
<lib-dashboard-rail
[label]="recommendationsRailLabel()"
[items]="recommendationCards()"
[totalCount]="recommendationCards().length"
[testId]="'dashboard-tmdb-recommendations-rail'"
/>
} @else if (
dashboardRails().tmdbRecommendations &&
recommendationsService.loading()
) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
<!-- TMDB trending rail (opt-in; Electron only — needs title matching) -->
@if (dashboardRails().tmdbTrending && trendingCards().length > 0) {
<lib-dashboard-rail
@@ -43,6 +43,8 @@ import {
DashboardDataService,
DashboardFavoriteItem,
DashboardRecentlyAddedItem,
DashboardRecommendationItem,
DashboardRecommendationsService,
DashboardSourceExpiryService,
DashboardTrendingItem,
DashboardTrendingService,
@@ -126,6 +128,7 @@ export class WorkspaceDashboardRailsComponent {
private readonly heroTmdb = inject(DashboardHeroTmdbService);
private readonly sourceExpiry = inject(DashboardSourceExpiryService);
readonly trendingService = inject(DashboardTrendingService);
readonly recommendationsService = inject(DashboardRecommendationsService);
readonly hasPlaylists = computed(() => 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<DashboardRailCard[]>(() => {
if (!this.recommendationsService.isAvailable) {
return [];
}
return this.recommendationsService
.items()
.map((item) => this.toRecommendationCard(item));
});
readonly recommendationsRailLabel = computed<string>(() => {
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'] {