fix(dashboard): give the hero the identity the detail view matched with (#1362)

The dashboard hero showed no backdrop for items whose detail page had one.
Two independent causes, both about identity rather than the matching gate.

Stalker items never reach the `content` table, so the xtream back-fill of
`content.backdrop_url` has no equivalent for them — but the enriched backdrop
is already sitting in the stored playlist entry (`info.tmdb_backdrop`). The
activity mappers now surface it as `backdrop_url`, where the hero already
looks first.

The hero's TMDB lookup ran on the display title alone, while the detail view
searched with the original title and the release year. Without a year
`pickConfidentMatch` requires a single exact title match, which common titles
never satisfy, and the miss lands in the negative cache under a lookup key the
detail view's hit can never be found at. The query is now built from the same
fields (`extractStalkerItemTmdbHints`), and the resolved `tmdb_id`
short-circuits the search entirely.

A 'movie' verdict retries as 'tv' without the id — 'movie' is the answer every
row falls back to, and an id is valid only for its own media type. A 'tv'
verdict, reached only on positive series evidence, gets no retry back to
'movie'.

Also removes `buildStalkerRecentItems`, a dead duplicate of the mapper the
dashboard actually uses.
This commit is contained in:
4gray authored and GitHub committed 2026-08-02 20:31:51 +02:00
1 parent b92503feae
commit 83f6a270a5
15 files changed
+660 -109

No files matched your search

@@ -0,0 +1,10 @@
---
type: fix
area: dashboard
---
The dashboard hero now shows the same backdrop as the detail page. Stalker
movies and series reuse the artwork saved when you opened them, and the
metadata lookup behind the hero uses the release year and original title
instead of the bare title — so titles TMDB knows more than once ("Холод")
no longer come up empty.
+1 -1
View File
@@ -1010,7 +1010,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 session; 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
- 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`
- 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`
+36 -2
View File
@@ -405,15 +405,49 @@ dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache
the worker at startup, and runs once per app session.
- **Hero extras**: `DashboardHeroTmdbService`
(`libs/workspace/dashboard/feature`) patches the hero card with a TMDB
backdrop (when the provider item has none), a rating badge and up to two
backdrop (when the activity row has none), a rating badge and up to two
genre chips — resolved through the enrichment facade, so items already
opened in a detail view come from the SQLite cache without network.
Results are memoized per title for the session. The hero renders
Results are memoized per lookup identity for the session. The hero renders
immediately from provider data; extras appear when resolved. Series
heroes additionally show the tracked "S{n}·E{n}" badge from the playback
position (no TMDB involved); the watch-progress bar is limited to
movie/series heroes.
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` requires a single
exact title match, which common titles never satisfy, and the miss lands
in the negative cache under a lookup key the detail view's hit can never
be found at. Stalker rows carry those facts —
`extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads
`info.name`, `info.o_name`, `info.releasedate` and `info.tmdb_id` off the
stored entry, mirroring `enrichStalkerSelectionWithTmdb` field for field.
A `'movie'` verdict gets a second attempt under `'tv'` — `'movie'` is what
every row falls back to when nothing says otherwise, and an embedded-VOD
series is a `'movie'` activity row but a show on TMDB. That retry **drops
the id**, which is only ever valid for the media type it was resolved
under: `/movie/<tv id>` resolves to an unrelated film. A `'tv'` verdict
gets no retry — it is reached only on positive evidence (series category,
`is_series`, or a non-empty episode array), and the gate cannot tell an
adaptation sharing its show's name and year from the show itself.
Note the id in a stored Stalker entry is not a provider claim. Stalker
portals never send one; its only source is a match this app already made
and gated.
### Stalker backdrops on activity rows
Xtream detail views back-fill `content.backdrop_url`, which the dashboard
reads directly. Stalker items never reach the `content` table, so their
backdrop travels inside the stored playlist entry instead
(`info.tmdb_backdrop`, written by the same merge that enriched the detail
view) and the activity mappers surface it as `backdrop_url` —
`mapStalkerPlaylistRecentItems` in `playlist-recently-viewed.utils.ts` and
`buildStalkerFavoriteItems` in `dashboard-mappers.ts`. Entries saved before
enrichment (or with TMDB off) simply have none, and the dashboard falls back
to a blurred poster.
### `badProviderId:` rows
Providers ship `tmdb_id` values that do not exist. A failed details fetch
@@ -7,6 +7,8 @@ import { Observable } from 'rxjs';
import { PlaylistsService } from '@iptvnator/services';
import {
createStalkerVodItem,
isStalkerSeriesFlag,
isStalkerSeriesItem,
StalkerVodDetails,
StalkerVodInfo,
VodDetailsItem,
@@ -47,9 +49,10 @@ export interface StalkerFavoriteToggleOptions {
onComplete?: () => void;
}
export function isStalkerSeriesFlag(value: unknown): boolean {
return value === true || value === 1 || value === '1';
}
// Single definition, shared with the activity-item normalizer and the TMDB
// hints reader: the dashboard has to reproduce the media type this predicate
// picks here, and a second copy would drift away from it.
export { isStalkerSeriesFlag, isStalkerSeriesItem };
export function normalizeStalkerSeriesFlag(
value: unknown
@@ -57,16 +60,6 @@ export function normalizeStalkerSeriesFlag(
return isStalkerSeriesFlag(value) ? true : undefined;
}
export function isStalkerSeriesItem(item: {
is_series?: unknown;
series?: unknown;
}): boolean {
return (
isStalkerSeriesFlag(item?.is_series) ||
(Array.isArray(item?.series) && item.series.length > 0)
);
}
export function buildStalkerFavoritePayload(
data: StalkerVodDetails
): Record<string, unknown> {
+1
View File
@@ -68,6 +68,7 @@ export * from './lib/xtream-vod-stream.interface';
// Stalker interfaces
export * from './lib/stalker-item.normalizer';
export * from './lib/stalker-item-tmdb-hints';
export * from './lib/stalker-identity.utils';
export * from './lib/stalker-request-identity.util';
export * from './lib/stalker-request-url.util';
@@ -1,5 +1,6 @@
import { PlaylistMeta } from './playlist-meta.type';
import { PortalRecentItem } from './portal-activity-item.interface';
import { extractStalkerItemTmdbHints } from './stalker-item-tmdb-hints';
import {
extractStalkerItemId,
extractStalkerItemPoster,
@@ -52,6 +53,10 @@ function mapStalkerPlaylistRecentItems(
category_id: String(item['category_id'] ?? ''),
xtream_id: id,
poster_url: extractStalkerItemPoster(item),
// Stalker has no `content` row to back-fill, so the backdrop
// travels inside the stored entry itself — present once the
// detail view has enriched the item.
backdrop_url: extractStalkerItemTmdbHints(item).backdropUrl,
source: 'stalker',
stalker_item: rawItem,
});
@@ -22,10 +22,13 @@ export interface PortalActivityItem {
*/
epg_lookup_key?: string;
/**
* Wide backdrop URL (typically 16:9), persisted when the user first views
* the detail page. Absent for stalker and for items that have never had
* their detail page opened. Dashboards should fall back to a blurred
* `poster_url` when undefined.
* Wide backdrop URL (typically 16:9), recorded when the user first views
* the detail page — for xtream on the `content` row, for stalker inside
* the stored playlist entry (`info.tmdb_backdrop`), since stalker items
* never reach the `content` table. Absent for items whose detail page has
* never been opened, and for stalker items opened without TMDB
* enrichment. Dashboards should fall back to a blurred `poster_url` when
* undefined.
*/
backdrop_url?: string;
source?: 'xtream' | 'stalker' | 'm3u';
@@ -0,0 +1,154 @@
import { buildPlaylistRecentItems } from './playlist-recently-viewed.utils';
import type { PlaylistMeta } from './playlist-meta.type';
import { extractStalkerItemTmdbHints } from './stalker-item-tmdb-hints';
import { extractStalkerItemType } from './stalker-item.normalizer';
/**
* The shape a Stalker recently-viewed entry has after the detail view
* enriched it: an embedded-VOD ("vclub") series, so `series` is populated
* while the category stays VOD.
*/
const enrichedEntry = {
id: '17573',
cmd: '/media/17573.mpg',
series: [1, 2, 3],
category_id: '7',
cover: 'https://image.tmdb.org/t/p/w500/poster.jpg',
title: 'Холод (10 серий)',
added_at: '2026-07-20T18:19:18.979Z',
info: {
name: 'Холод (10 серий)',
o_name: 'Kholod',
releasedate: '2026',
tmdb_id: 318354,
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/backdrop.jpg',
},
};
describe('extractStalkerItemTmdbHints', () => {
it('reads the facts the detail view merged into the stored entry', () => {
expect(extractStalkerItemTmdbHints(enrichedEntry)).toEqual({
backdropUrl: 'https://image.tmdb.org/t/p/w1280/backdrop.jpg',
mediaType: 'tv',
originalTitle: 'Kholod',
tmdbId: 318354,
title: 'Холод (10 серий)',
year: 2026,
});
});
it('types embedded-VOD series as tv even though the activity row is a movie', () => {
// The routing type must stay 'movie' (these live in the VOD section)
// while the TMDB lookup has to run against /tv — the two answers
// diverge on purpose.
expect(extractStalkerItemType(enrichedEntry)).toBe('movie');
expect(extractStalkerItemTmdbHints(enrichedEntry).mediaType).toBe('tv');
});
it('types regular series-category items as tv', () => {
expect(
extractStalkerItemTmdbHints({
id: '9',
category_id: 'series',
info: { name: 'The Boys' },
}).mediaType
).toBe('tv');
});
it('types a plain VOD item as movie', () => {
expect(
extractStalkerItemTmdbHints({
id: '9',
category_id: 'vod',
info: { name: 'Heat', releasedate: '1995-12-15' },
})
).toEqual({
backdropUrl: undefined,
mediaType: 'movie',
originalTitle: undefined,
tmdbId: undefined,
title: 'Heat',
year: 1995,
});
});
it('falls back to a year inside the title when there is no release date', () => {
expect(
extractStalkerItemTmdbHints({
info: { name: 'Dune (2021)' },
}).year
).toBe(2021);
});
it('reports no title when the entry carries no info block', () => {
// Callers keep their own display title instead of searching for a
// placeholder such as "Unknown".
expect(extractStalkerItemTmdbHints({ id: '1', title: 'X' })).toEqual({
backdropUrl: undefined,
mediaType: 'movie',
originalTitle: undefined,
tmdbId: undefined,
title: undefined,
year: null,
});
});
it('ignores blank and non-positive-integer values', () => {
expect(
extractStalkerItemTmdbHints({
info: {
name: 'X',
o_name: ' ',
tmdb_backdrop: '',
tmdb_id: 'not-a-number',
},
})
).toEqual({
backdropUrl: undefined,
mediaType: 'movie',
originalTitle: undefined,
tmdbId: undefined,
title: 'X',
year: null,
});
});
it('tolerates a missing item', () => {
expect(extractStalkerItemTmdbHints(undefined).mediaType).toBe('movie');
expect(extractStalkerItemTmdbHints(null).year).toBeNull();
});
});
describe('buildPlaylistRecentItems (stalker backdrops)', () => {
const labels = { stalker: 'Stalker', m3u: 'M3U' };
function recentItemsOf(rawItem: unknown) {
return buildPlaylistRecentItems(
[
{
_id: 'portal-1',
title: 'RUcolor.tv',
macAddress: '00:1A:79:00:00:01',
recentlyViewed: [rawItem],
} as unknown as PlaylistMeta,
],
labels
);
}
it('surfaces the enriched backdrop so the hero does not need a lookup', () => {
expect(recentItemsOf(enrichedEntry)[0].backdrop_url).toBe(
'https://image.tmdb.org/t/p/w1280/backdrop.jpg'
);
});
it('leaves the backdrop undefined for an entry saved without enrichment', () => {
expect(
recentItemsOf({
id: '17573',
title: 'Холод (10 серий)',
cover: 'https://image.tmdb.org/t/p/w500/poster.jpg',
})[0].backdrop_url
).toBeUndefined();
});
});
@@ -0,0 +1,80 @@
import { isStalkerSeriesItem } from './stalker-item.normalizer';
import { StalkerPortalItem } from './stalker-portal-item.interface';
import { extractYear } from './title-normalization.util';
import { TmdbMediaType } from './tmdb.interface';
/**
* What a stored Stalker favorite / recently-viewed entry already knows about
* its TMDB match.
*
* These items are written as a spread of the item the detail view had on
* screen, so an entry saved after enrichment carries the merged result:
* the backdrop, the resolved id, the release date and the original title.
* Anything reading such an entry (the dashboard hero above all) should use
* those facts instead of starting a fresh title search — the search runs on
* strictly less identity than the detail view had, and the confidence gate
* turns that difference into a miss.
*/
export interface StalkerItemTmdbHints {
/** Wide backdrop merged in by the detail view, when it got that far */
readonly backdropUrl?: string;
/**
* The media type the detail view enriched under — NOT the activity type.
* Embedded-VOD series stay `'movie'` as activity rows (they route into
* the VOD section) while TMDB knows them as shows.
*/
readonly mediaType: TmdbMediaType;
readonly originalTitle?: string;
/**
* TMDB id from a previous merge. Unlike an Xtream `tmdb_id`, this is not
* a provider claim — Stalker portals never send one, so its only source
* is a match this app already made and gated.
*/
readonly tmdbId?: number;
/**
* The title the detail view searched with (`info.name`). Absent when the
* entry carries no `info`, in which case the caller keeps its own display
* title — never a placeholder that would search for nothing.
*/
readonly title?: string;
readonly year: number | null;
}
function readString(value: unknown): string | undefined {
const text = typeof value === 'string' ? value.trim() : '';
return text || undefined;
}
function readTmdbId(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
/**
* Read the TMDB facts a stored Stalker item carries. The query fields mirror
* `enrichStalkerSelectionWithTmdb` exactly — same title, same original
* title, same year, same media type — so a lookup built from these hints
* lands on the cache row the detail view already filled instead of minting a
* second, weaker one.
*/
export function extractStalkerItemTmdbHints(
item: StalkerPortalItem | Record<string, unknown> | null | undefined
): StalkerItemTmdbHints {
const raw = (item ?? {}) as Record<string, unknown>;
const info = (raw['info'] ?? {}) as Record<string, unknown>;
const title = readString(info['name']);
const categoryId = String(raw['category_id'] ?? '').toLowerCase();
return {
backdropUrl: readString(info['tmdb_backdrop']),
mediaType:
categoryId === 'series' || isStalkerSeriesItem(raw)
? 'tv'
: 'movie',
originalTitle: readString(info['o_name']),
tmdbId: readTmdbId(info['tmdb_id']),
title,
year: extractYear(readString(info['releasedate']), title),
};
}
@@ -46,12 +46,37 @@ export function extractStalkerItemPoster(
return String(raw['cover'] ?? raw['logo'] ?? raw['poster_url'] ?? '');
}
/** Portals spell the series marker as `true`, `1`, or `'1'`. */
export function isStalkerSeriesFlag(value: unknown): boolean {
return value === true || value === 1 || value === '1';
}
/**
* Does this item behave like a series? Beyond the explicit flag, an
* embedded-VOD ("vclub") item announces its episodes through a non-empty
* `series` array while staying in the VOD category.
*/
export function isStalkerSeriesItem(item: {
is_series?: unknown;
series?: unknown;
}): boolean {
return (
isStalkerSeriesFlag(item?.is_series) ||
(Array.isArray(item?.series) && item.series.length > 0)
);
}
/**
* Determine the normalised activity type of a Stalker item.
*
* - `itv` / `live` / radio → `'live'`
* - `series` or `is_series` equal to `true`, `1`, or `'1'` → `'series'`
* - everything else → `'movie'`
*
* Deliberately blind to the `series` episode array: embedded-VOD items live
* in the VOD section and must keep routing there, so they stay `'movie'`
* here even though TMDB knows them as shows. Metadata lookups need the other
* answer — see {@link isStalkerSeriesItem}.
*/
export function extractStalkerItemType(
item: StalkerPortalItem | Record<string, unknown>
@@ -68,12 +93,7 @@ export function extractStalkerItemType(
return 'live';
}
if (
categoryId === 'series' ||
raw['is_series'] === true ||
raw['is_series'] === 1 ||
raw['is_series'] === '1'
) {
if (categoryId === 'series' || isStalkerSeriesFlag(raw['is_series'])) {
return 'series';
}
@@ -0,0 +1,50 @@
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { buildStalkerFavoriteItems } from './dashboard-mappers';
describe('buildStalkerFavoriteItems', () => {
function favoritesOf(favorite: unknown) {
return buildStalkerFavoriteItems(
[
{
_id: 'portal-1',
title: 'RUcolor.tv',
macAddress: '00:1A:79:00:00:01',
favorites: [favorite],
} as unknown as PlaylistMeta,
],
'Stalker'
);
}
it('surfaces the backdrop the detail view merged into the entry', () => {
// Stalker items never reach the `content` table, so the enriched
// backdrop travels inside the stored favorite itself.
const [item] = favoritesOf({
id: '17573',
category_id: '7',
title: 'Холод (10 серий)',
cover: 'https://image.tmdb.org/t/p/w500/poster.jpg',
info: {
name: 'Холод (10 серий)',
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/backdrop.jpg',
},
});
expect(item.backdrop_url).toBe(
'https://image.tmdb.org/t/p/w1280/backdrop.jpg'
);
expect(item.poster_url).toBe(
'https://image.tmdb.org/t/p/w500/poster.jpg'
);
});
it('leaves the backdrop undefined for an entry saved without enrichment', () => {
const [item] = favoritesOf({
id: '17573',
title: 'Холод (10 серий)',
cover: 'https://image.tmdb.org/t/p/w500/poster.jpg',
});
expect(item.backdrop_url).toBeUndefined();
});
});
@@ -12,6 +12,7 @@ import {
extractStalkerItemId,
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemTmdbHints,
extractStalkerItemType,
normalizeStalkerDate,
} from '@iptvnator/shared/interfaces';
@@ -83,44 +84,6 @@ export function mapDbRecentlyAddedToItem(
// ────── Stalker playlist → ViewModel ──────
export function buildStalkerRecentItems(
playlists: PlaylistMeta[],
defaultPlaylistName: string
): PortalRecentItem[] {
return playlists
.filter((playlist) => Boolean(playlist.macAddress))
.reduce<PortalRecentItem[]>((acc, playlist) => {
const recentItems = Array.isArray(
(playlist as { recentlyViewed?: unknown[] }).recentlyViewed
)
? ((playlist as { recentlyViewed?: unknown[] })
.recentlyViewed ?? [])
: [];
const mapped = recentItems.map((rawItem, index) => {
const item = (rawItem ?? {}) as Record<string, unknown>;
const id = extractStalkerItemId(item, playlist._id, index);
return {
id,
title: extractStalkerItemTitle(item),
type: extractStalkerItemType(item),
playlist_id: playlist._id,
playlist_name: playlist.title || defaultPlaylistName,
viewed_at: normalizeStalkerDate(item['added_at']),
category_id: String(item['category_id'] ?? ''),
xtream_id: id,
poster_url: extractStalkerItemPoster(item),
source: 'stalker' as const,
stalker_item: rawItem,
} as PortalRecentItem;
});
acc.push(...mapped);
return acc;
}, []);
}
export function buildStalkerFavoriteItems(
playlists: PlaylistMeta[],
defaultPlaylistName: string
@@ -146,6 +109,9 @@ export function buildStalkerFavoriteItems(
category_id: String(raw['category_id'] ?? ''),
xtream_id: id,
poster_url: extractStalkerItemPoster(raw),
// Same source as the recent-items mapper: Stalker keeps
// the enriched backdrop inside the stored entry.
backdrop_url: extractStalkerItemTmdbHints(raw).backdropUrl,
source: 'stalker' as const,
stalker_item: item,
} as PortalFavoriteItem;
@@ -1,6 +1,9 @@
import { TestBed } from '@angular/core/testing';
import { TmdbEnrichmentService } from '@iptvnator/services';
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
import {
DashboardHeroTmdbItem,
DashboardHeroTmdbService,
} from './dashboard-hero-tmdb.service';
describe('DashboardHeroTmdbService', () => {
const tvDetails = {
@@ -11,6 +14,44 @@ describe('DashboardHeroTmdbService', () => {
genres: [{ id: 1, name: 'Drama' }, { id: 2, name: 'Comedy' }],
};
/**
* A Stalker recently-viewed entry as stored after the detail view merged
* TMDB into it: an embedded-VOD series, so the activity row is typed
* 'movie' while TMDB knows it as a show.
*/
const stalkerItem: DashboardHeroTmdbItem = {
title: 'Холод (10 серий)',
type: 'movie',
stalker_item: {
id: '17573',
category_id: '7',
series: [1, 2, 3],
title: 'Холод (10 серий)',
info: {
name: 'Холод (10 серий)',
o_name: 'Kholod',
releasedate: '2026',
tmdb_id: 318354,
},
},
} as unknown as DashboardHeroTmdbItem;
/** A plain VOD entry: nothing marks it as a series, so it leads with movie */
const plainVodItem = {
title: 'The Matrix',
type: 'movie',
stalker_item: {
id: '42',
category_id: 'vod',
title: 'The Matrix',
info: {
name: 'The Matrix',
releasedate: '1999-03-31',
tmdb_id: 603,
},
},
} as unknown as DashboardHeroTmdbItem;
let isEnabled: jest.Mock;
let enrichMovie: jest.Mock;
let enrichTv: jest.Mock;
@@ -48,7 +89,6 @@ describe('DashboardHeroTmdbService', () => {
});
it('falls back to a TV lookup for movie-typed items without a movie match', async () => {
// Stalker embedded-series items are typed 'movie' in activity rows
const service = createService();
const extras = await service.getExtras({
@@ -63,6 +103,20 @@ describe('DashboardHeroTmdbService', () => {
expect(extras?.genres).toEqual(['Drama', 'Comedy']);
});
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
// traded for a same-titled film — the gate cannot tell an
// adaptation sharing its show's name and year from the show itself.
enrichTv.mockResolvedValue(null);
const service = createService();
await expect(
service.getExtras({ title: 'Fargo', type: 'series' })
).resolves.toBeNull();
expect(enrichMovie).not.toHaveBeenCalled();
});
it('does not retry as TV when the movie lookup succeeds', async () => {
enrichMovie.mockResolvedValue({ ...tvDetails, id: 200 });
const service = createService();
@@ -72,7 +126,7 @@ describe('DashboardHeroTmdbService', () => {
expect(enrichTv).not.toHaveBeenCalled();
});
it('memoizes results per title and type', async () => {
it('memoizes results per lookup identity', async () => {
const service = createService();
await service.getExtras({ title: 'The Boys', type: 'series' });
@@ -80,4 +134,82 @@ describe('DashboardHeroTmdbService', () => {
expect(enrichTv).toHaveBeenCalledTimes(1);
});
describe('stalker items', () => {
it('queries with the year, original title and id the detail view used', async () => {
// Without these the search runs on the title alone, and the
// confidence gate rejects any title TMDB knows more than once.
const service = createService();
const extras = await service.getExtras(stalkerItem);
expect(enrichMovie).not.toHaveBeenCalled();
expect(enrichTv).toHaveBeenCalledWith({
title: 'Холод (10 серий)',
originalTitle: 'Kholod',
tmdbId: 318354,
year: 2026,
});
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.
enrichMovie.mockResolvedValue(null);
const service = createService();
await service.getExtras(plainVodItem);
expect(enrichMovie).toHaveBeenCalledWith(
expect.objectContaining({ tmdbId: 603, year: 1999 })
);
expect(enrichTv).toHaveBeenCalledWith(
expect.objectContaining({ tmdbId: undefined, year: 1999 })
);
});
it('does not retry a series as a movie', async () => {
enrichTv.mockResolvedValue(null);
const service = createService();
await expect(service.getExtras(stalkerItem)).resolves.toBeNull();
expect(enrichMovie).not.toHaveBeenCalled();
});
it('does not search for an entry that has no usable name', async () => {
const service = createService();
await expect(
service.getExtras({
title: 'Unknown',
type: 'movie',
stalker_item: { id: '1' },
} as unknown as DashboardHeroTmdbItem)
).resolves.toBeNull();
expect(enrichMovie).not.toHaveBeenCalled();
expect(enrichTv).not.toHaveBeenCalled();
});
it('keys same-titled items apart by the facts behind them', async () => {
const service = createService();
const remake = {
...stalkerItem,
stalker_item: {
...(stalkerItem.stalker_item as object),
info: {
name: 'Холод (10 серий)',
releasedate: '1984',
},
},
} as DashboardHeroTmdbItem;
expect(service.keyFor(stalkerItem)).not.toBe(service.keyFor(remake));
await service.getExtras(stalkerItem);
await service.getExtras(remake);
expect(enrichTv).toHaveBeenCalledTimes(2);
});
});
});
@@ -4,6 +4,10 @@ import {
extractYear,
tmdbBackdropUrl,
} from '@iptvnator/services';
import {
TmdbMediaType,
extractStalkerItemTmdbHints,
} from '@iptvnator/shared/interfaces';
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
/** TMDB extras for the dashboard hero, patched in after first paint */
@@ -13,13 +17,40 @@ export interface DashboardHeroTmdbExtras {
readonly genres: readonly string[];
}
/** 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;
}
const MAX_HERO_GENRES = 2;
/**
* Best-effort TMDB extras for the hero card (backdrop, rating, genres).
* Goes through the enrichment facade, so items already opened in a detail
* view resolve from the SQLite cache without network. Results are memoized
* per title for the session — dashboard revisits skip the IPC round-trip.
* 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.
*/
@Injectable({ providedIn: 'root' })
export class DashboardHeroTmdbService {
@@ -34,68 +65,140 @@ export class DashboardHeroTmdbService {
return this.enrichment.isEnabled();
}
/**
* 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.
*/
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}`;
}
getExtras(
item: Pick<GlobalRecentItem, 'title' | 'type'>
item: DashboardHeroTmdbItem
): Promise<DashboardHeroTmdbExtras | null> {
if (
!this.enrichment.isEnabled() ||
(item.type !== 'movie' && item.type !== 'series')
) {
const attempts = this.buildAttempts(item);
if (!this.enrichment.isEnabled() || attempts.length === 0) {
return Promise.resolve(null);
}
const key = `${item.type}:${item.title}`;
const key = this.keyFor(item);
const cached = this.memo.get(key);
if (cached) {
return cached;
}
const pending = this.loadExtras(item);
const pending = this.loadExtras(attempts);
this.memo.set(key, pending);
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(
item: Pick<GlobalRecentItem, 'title' | 'type'>
attempts: readonly HeroTmdbAttempt[]
): Promise<DashboardHeroTmdbExtras | null> {
try {
const query = {
title: item.title,
year: extractYear(null, item.title),
};
let details =
item.type === 'movie'
? await this.enrichment.enrichMovie(query)
: await this.enrichment.enrichTv(query);
if (!details && item.type === 'movie') {
// Stalker embedded-series items (vclub) are typed 'movie'
// in activity rows but are TV shows on TMDB — the detail
// view resolves them via is_series, so the TV lookup is
// usually already cached. Misses are negative-cached, so
// the retry never turns into repeated network traffic.
details = await this.enrichment.enrichTv(query);
}
if (!details) {
return null;
for (const attempt of attempts) {
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 toHeroExtras(details);
}
}
const rating =
(details.vote_count ?? 0) > 0 && details.vote_average
? details.vote_average.toFixed(1)
: null;
const genres = (details.genres ?? [])
.map((genre) => genre.name)
.filter(Boolean)
.slice(0, MAX_HERO_GENRES);
return {
backdropUrl: tmdbBackdropUrl(details.backdrop_path),
rating,
genres,
};
return null;
} catch (error) {
console.warn('Dashboard hero TMDB extras failed:', error);
return null;
}
}
}
function toHeroExtras(details: {
backdrop_path?: string | null;
vote_average?: number;
vote_count?: number;
genres?: { name?: string }[];
}): DashboardHeroTmdbExtras {
const rating =
(details.vote_count ?? 0) > 0 && details.vote_average
? details.vote_average.toFixed(1)
: null;
return {
backdropUrl: tmdbBackdropUrl(details.backdrop_path),
rating,
genres: (details.genres ?? [])
.map((genre) => genre.name)
.filter((name): name is string => Boolean(name))
.slice(0, MAX_HERO_GENRES),
};
}
@@ -599,7 +599,7 @@ export class WorkspaceDashboardRailsComponent {
}
private heroTmdbKey(item: GlobalRecentItem): string {
return `${item.type}:${item.title}`;
return this.heroTmdb.keyFor(item);
}
private toTrendingCard(item: DashboardTrendingItem): DashboardRailCard {