From 83f6a270a59e9e5b7acfc803a643c43e99a5f719 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:31:51 +0200 Subject: [PATCH] fix(dashboard): give the hero the identity the detail view matched with (#1362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changes/dashboard-hero-backdrop-stalker.md | 10 + CLAUDE.md | 2 +- docs/architecture/tmdb-metadata-enrichment.md | 38 +++- .../data-access/src/lib/stalker-vod.utils.ts | 19 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/playlist-recently-viewed.utils.ts | 5 + .../src/lib/portal-activity-item.interface.ts | 11 +- .../src/lib/stalker-item-tmdb-hints.spec.ts | 154 +++++++++++++++ .../src/lib/stalker-item-tmdb-hints.ts | 80 ++++++++ .../src/lib/stalker-item.normalizer.ts | 32 ++- .../src/lib/dashboard-mappers.spec.ts | 50 +++++ .../data-access/src/lib/dashboard-mappers.ts | 42 +--- .../rails/dashboard-hero-tmdb.service.spec.ts | 138 ++++++++++++- .../lib/rails/dashboard-hero-tmdb.service.ts | 185 ++++++++++++++---- .../workspace-dashboard-rails.component.ts | 2 +- 15 files changed, 660 insertions(+), 109 deletions(-) create mode 100644 .changes/dashboard-hero-backdrop-stalker.md create mode 100644 libs/shared/interfaces/src/lib/stalker-item-tmdb-hints.spec.ts create mode 100644 libs/shared/interfaces/src/lib/stalker-item-tmdb-hints.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.spec.ts diff --git a/.changes/dashboard-hero-backdrop-stalker.md b/.changes/dashboard-hero-backdrop-stalker.md new file mode 100644 index 000000000..63a712acc --- /dev/null +++ b/.changes/dashboard-hero-backdrop-stalker.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 9143e8159..14406ad6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 146bfa063..8dad407cd 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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/` 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 diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index bd0ae96ae..aca6cd3c2 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -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 { diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 4593b09ab..3417f954b 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts index c20219417..1ce60d2ee 100644 --- a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts +++ b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts @@ -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, }); diff --git a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts index b3119dc6b..41b5053b9 100644 --- a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/stalker-item-tmdb-hints.spec.ts b/libs/shared/interfaces/src/lib/stalker-item-tmdb-hints.spec.ts new file mode 100644 index 000000000..1ffc9bb62 --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-item-tmdb-hints.spec.ts @@ -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(); + }); +}); diff --git a/libs/shared/interfaces/src/lib/stalker-item-tmdb-hints.ts b/libs/shared/interfaces/src/lib/stalker-item-tmdb-hints.ts new file mode 100644 index 000000000..0c148a6e9 --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-item-tmdb-hints.ts @@ -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 | null | undefined +): StalkerItemTmdbHints { + const raw = (item ?? {}) as Record; + const info = (raw['info'] ?? {}) as Record; + + 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), + }; +} diff --git a/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts b/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts index 2c630fef2..2481ed5cf 100644 --- a/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts +++ b/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts @@ -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 @@ -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'; } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.spec.ts new file mode 100644 index 000000000..ea1d24687 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.spec.ts @@ -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(); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts index 0dd42f007..0cc69a600 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts @@ -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((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; - 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; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts index ccbd48a1b..1cf416a33 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts @@ -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/ 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); + }); + }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts index 2a449d5d9..8d7e304cc 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts @@ -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/` 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 + item: DashboardHeroTmdbItem ): Promise { - 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/` 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 + attempts: readonly HeroTmdbAttempt[] ): Promise { 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), + }; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 810728126..1ff8a673a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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 {