From d58ca4ce1853626be1f72d203d2adc9a6aa2ef95 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 09:06:35 +0200 Subject: [PATCH] fix(stalker): keep TMDB genre and country chips on Stalker series The series view re-normalizes the selected item through createStalkerInfo() on every render, and that mapping carried only seven hand-picked tmdb_* fields. tmdb_genres, tmdb_countries, tmdb_media_type and tmdb_supplied_release_date were dropped right after enrichment, so the clickable Discover chips never appeared on Stalker series. The carried keys now come from a Record over StalkerVodInfo's tmdb_* keys, so a new enrichment field fails to compile until it is listed. Saved Stalker entries now also keep tmdb_supplied_release_date, and the recommendations exclusion index (trustedReleaseYear) skips a date TMDB supplied, as the flag's contract requires. Co-Authored-By: Claude Opus 5.5 --- .changes/stalker-series-discover-chips.md | 6 ++ docs/architecture/stalker-portal.md | 6 ++ docs/architecture/tmdb-metadata-enrichment.md | 6 +- .../src/lib/stalker-vod.utils.spec.ts | 47 +++++++++++--- .../data-access/src/lib/stalker-vod.utils.ts | 48 ++++++++++---- .../stalker-series-view.tmdb-facets.spec.ts | 64 +++++++++++++++++++ .../src/lib/stalker-vod-details.interface.ts | 2 + .../dashboard-recommendations.service.spec.ts | 39 +++++++++++ .../src/lib/dashboard-recommendations.util.ts | 8 ++- 9 files changed, 202 insertions(+), 24 deletions(-) create mode 100644 .changes/stalker-series-discover-chips.md create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts diff --git a/.changes/stalker-series-discover-chips.md b/.changes/stalker-series-discover-chips.md new file mode 100644 index 000000000..db6945f23 --- /dev/null +++ b/.changes/stalker-series-discover-chips.md @@ -0,0 +1,6 @@ +--- +type: fix +area: stalker +--- + +Stalker series details now keep their clickable genre and country chips after TMDB fills in the details, so you can jump to Discover from them as you can for movies. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index c2aee3afc..90997a8e2 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -432,6 +432,12 @@ Detail metadata rule: [Embedded Inline Playback](./embedded-inline-playback.md). It runs before TMDB enrichment, which keeps a non-empty provider rating and release date, so a surviving stand-in would also block the TMDB value. +- It also runs AFTER enrichment: the series view re-normalizes the store item + on every render, so it carries every `tmdb_*` field over. The key list + (`TMDB_ENRICHMENT_KEYS`) is typed as a `Record` over `StalkerVodInfo`'s + `tmdb_*` keys, so a new enrichment field fails to compile until it is listed + there instead of silently vanishing from the view (as the genre and country + Discover chips once did). ## Stalker Identity Policy diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 606eb72fa..3b2af24bd 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -832,8 +832,10 @@ Contracts worth keeping: credits and no recommendations carries none of them and reads as un-enriched. Since the column is never overwritten, getting this wrong is unfixable after the fact — a real provider date arriving later cannot - correct it. The same marker is what `trustedReleaseYear` (the - recommendations exclusion index) must consult if it ever reads this column. + correct it. Stalker entries never reach this column, but the same marker + rides in their stored `info`, and `trustedReleaseYear` (the recommendations + exclusion index) reads it first: a date TMDB supplied does not gate the + exact tier. `createStalkerInfo()` keeps the marker through re-normalization. - **The id is stored unvetted.** Every consumer reaches TMDB through `TmdbEnrichmentService`, whose `detailsForProviderId` runs `assessProviderId` and lets the title search take over when the years diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index 23dfe09cb..b0bc7563b 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -253,44 +253,71 @@ describe('stalker-vod.utils regressions', () => { }); it('preserves TMDB enrichment fields through info re-normalization', () => { - const tmdbCast = [{ name: 'Karl Urban', profileUrl: null }]; + // Detail views re-normalize the selected item on every render, so + // every field `mergeStalkerInfoWithTmdb` can set has to survive here + const tmdbCast = [{ name: 'Rowan Quill', profileUrl: null }]; const tmdbRecommendations = [ - { tmdbId: 1, title: 'Invincible', year: 2021, posterUrl: null }, + { tmdbId: 1, title: 'Harbor Static', year: 2021, posterUrl: null }, ]; + const tmdbGenres = [{ id: 18, name: 'Drama' }]; + const tmdbCountries = [{ code: 'NO', name: 'Norway' }]; const info = createStalkerInfo({ id: '7', info: { - name: 'The Boys s05', + name: 'Lantern Coast s05', movie_image: 'http://portal/poster.jpg', description: 'Plot', - actors: 'Karl Urban', + actors: 'Rowan Quill', director: '', - releasedate: '2026', - genre: 'Action', + releasedate: '2026-03-01', + genre: 'Drama', rating_imdb: '', rating_kinopoisk: '8.1', + tmdb_id: 4242, tmdb_cast: tmdbCast, tmdb_directors: [ { - name: 'Eric Kripke', + name: 'Mara Tollen', profileUrl: null, tmdbPersonId: 1216630, }, ], - tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/boys.jpg', + tmdb_status: 'returning', + tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/coast.jpg', tmdb_trailer: 'abc123def', tmdb_recommendations: tmdbRecommendations, + tmdb_genres: tmdbGenres, + tmdb_countries: tmdbCountries, + tmdb_media_type: 'tv', + tmdb_supplied_release_date: true, }, }); + expect(info.tmdb_id).toBe(4242); expect(info.tmdb_cast).toEqual(tmdbCast); - expect(info.tmdb_directors?.[0]?.name).toBe('Eric Kripke'); + expect(info.tmdb_directors?.[0]?.name).toBe('Mara Tollen'); + expect(info.tmdb_status).toBe('returning'); expect(info.tmdb_backdrop).toBe( - 'https://image.tmdb.org/t/p/w1280/boys.jpg' + 'https://image.tmdb.org/t/p/w1280/coast.jpg' ); expect(info.tmdb_trailer).toBe('abc123def'); expect(info.tmdb_recommendations).toEqual(tmdbRecommendations); + expect(info.tmdb_genres).toEqual(tmdbGenres); + expect(info.tmdb_countries).toEqual(tmdbCountries); + expect(info.tmdb_media_type).toBe('tv'); + expect(info.tmdb_supplied_release_date).toBe(true); + }); + + it('adds no TMDB fields to a row that was never enriched', () => { + const info = createStalkerInfo({ + id: '8', + info: { name: 'Unenriched Title', releasedate: '2019' }, + }); + + expect( + Object.keys(info).filter((key) => key.startsWith('tmdb_')) + ).toEqual([]); }); describe('temporary-link flags survive normalization', () => { 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 f650aa371..7ec8dc9a8 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 @@ -188,20 +188,46 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo { genre: text(info.genre, item.genre, item.genres_str), rating_imdb: rating(info.rating_imdb, item.rating_imdb), rating_kinopoisk: rating(info.rating_kinopoisk, item.rating_kinopoisk), - // TMDB enrichment fields must survive re-normalization — detail - // views normalize the selected item on every render - ...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}), - ...(info.tmdb_cast ? { tmdb_cast: info.tmdb_cast } : {}), - ...(info.tmdb_directors ? { tmdb_directors: info.tmdb_directors } : {}), - ...(info.tmdb_status ? { tmdb_status: info.tmdb_status } : {}), - ...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}), - ...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}), - ...(info.tmdb_recommendations - ? { tmdb_recommendations: info.tmdb_recommendations } - : {}), + ...pickTmdbEnrichment(info), }; } +type TmdbEnrichmentKey = Extract; + +/** + * Every TMDB enrichment field of {@link StalkerVodInfo}. A `Record` over the + * `tmdb_*` keys, so adding one to the interface fails to compile until it is + * listed here: detail views re-normalize the selected item on every render, + * and a field this list missed would vanish right after enrichment. + */ +const TMDB_ENRICHMENT_KEYS: Record = { + tmdb_id: true, + tmdb_cast: true, + tmdb_directors: true, + tmdb_status: true, + tmdb_backdrop: true, + tmdb_trailer: true, + tmdb_recommendations: true, + tmdb_genres: true, + tmdb_countries: true, + tmdb_media_type: true, + tmdb_supplied_release_date: true, +}; + +function pickTmdbEnrichment( + info: Partial +): Partial> { + const picked: Record = {}; + for (const key of Object.keys( + TMDB_ENRICHMENT_KEYS + ) as TmdbEnrichmentKey[]) { + if (info[key]) { + picked[key] = info[key]; + } + } + return picked; +} + /** * Narrows a raw portal row to the fields the detail/playback flows need. * diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts new file mode 100644 index 000000000..f0a343a66 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts @@ -0,0 +1,64 @@ +import { ComponentFixture } from '@angular/core/testing'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; +import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness'; + +/** + * The view re-normalizes the selected item on every render, so the TMDB + * genre and country facets the enrichment merged into the store item have + * to survive that pass to become clickable Discover chips. + */ +describe('StalkerSeriesViewComponent TMDB facet chips', () => { + let fixture: ComponentFixture; + const { selectedItem, createFixture } = createStalkerSeriesViewHarness(() => + jest.fn() + ); + + async function stabilize(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + } + + function facetChipLabels(): string[] { + return Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + 'button.meta-chip__facet' + ) + ).map((chip) => chip.textContent?.trim() ?? ''); + } + + beforeEach(async () => { + fixture = await createFixture(); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('renders the enriched genres and countries as Discover chips', async () => { + await stabilize(); + + // Enrichment patches the store item after selection + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series', + description: 'Series description', + movie_image: 'poster.jpg', + genre: 'Drama', + tmdb_id: 777, + tmdb_genres: [ + { id: 18, name: 'Drama' }, + { id: 9648, name: 'Mystery' }, + ], + tmdb_countries: [{ code: 'NO', name: 'Norway' }], + tmdb_media_type: 'tv', + }, + } as never); + await stabilize(); + + expect(facetChipLabels()).toEqual( + expect.arrayContaining(['Drama', 'Mystery', 'Norway']) + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts index 32c7d5aef..8a87d4258 100644 --- a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts @@ -64,4 +64,6 @@ export interface StalkerVodInfo { * this to send Discover clicks to the right media type. */ tmdb_media_type?: TmdbMediaType; + /** See `XtreamVodInfo.tmdb_supplied_release_date` — same contract */ + tmdb_supplied_release_date?: boolean; } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts index 10602e866..9dd0d806a 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts @@ -885,6 +885,45 @@ describe('DashboardRecommendationsService', () => { expect(service.items().map((item) => item.title)).toContain('Godzilla'); }); + it('ignores a release date the TMDB merge supplied when excluding', async () => { + // The portal sent no date, so the stored one is the year of + // whatever TMDB matched. It must not gate the exact tier, or a + // namesake match would keep the watched title on the rail. + enrichMovie.mockResolvedValue({ + recommendations: { + results: [ + ...recTitles + .slice(0, 5) + .map((title, i) => rec(100 + i, title)), + rec(200, 'Quarry Lights', 2014), + ], + }, + }); + recentAll = [ + ...recentVod, + { + title: 'Quarry Lights', + type: 'movie', + stalker_item: { + id: '9', + category_id: 'vod', + info: { + name: 'Quarry Lights', + releasedate: '1954-11-03', + tmdb_supplied_release_date: true, + }, + }, + } as ActivityStub, + ]; + const service = createService(); + + await service.load(); + + expect(service.items().map((item) => item.title)).not.toContain( + 'Quarry Lights' + ); + }); + it('excludes a watched title whose name ends in a year', async () => { // "Blade Runner 2049" is a 2017 film; the 2049 belongs to the // NAME, so it must not be read as a release year and used to diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts index a232249de..3aa628293 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts @@ -52,7 +52,10 @@ export type RecommendationCandidate = Omit< * 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. + * which keeps the exact tier excluding unconditionally. So does a date the + * TMDB merge filled in itself (`tmdb_supplied_release_date`): it is the + * year of whatever TMDB matched, and a namesake match would otherwise stop + * the watched title from excluding itself. * * The base tier is different by construction — its key exists only * because a trailing year was stripped off — so it keeps using that year. @@ -63,6 +66,9 @@ export function trustedReleaseYear( const info = ( item.stalker_item as { info?: Record } | undefined )?.info; + if (info?.['tmdb_supplied_release_date'] === true) { + return null; + } const releaseDate = info?.['releasedate']; return typeof releaseDate === 'string' ? extractYear(releaseDate) : null; }