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

@@ -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}`;
}