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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 09:06:35 +02:00
1 parent ebb1ab3467
commit d58ca4ce18
9 files changed
+202 -24

No files matched your search

@@ -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.
+6
View File
@@ -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
@@ -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
@@ -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', () => {
@@ -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<keyof StalkerVodInfo, `tmdb_${string}`>;
/**
* 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<TmdbEnrichmentKey, true> = {
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<StalkerVodInfo>
): Partial<Pick<StalkerVodInfo, TmdbEnrichmentKey>> {
const picked: Record<string, unknown> = {};
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.
*
@@ -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<StalkerSeriesViewComponent>;
const { selectedItem, createFixture } = createStalkerSeriesViewHarness(() =>
jest.fn()
);
async function stabilize(): Promise<void> {
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'])
);
});
});
@@ -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;
}
@@ -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
@@ -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<string, unknown> } | undefined
)?.info;
if (info?.['tmdb_supplied_release_date'] === true) {
return null;
}
const releaseDate = info?.['releasedate'];
return typeof releaseDate === 'string' ? extractYear(releaseDate) : null;
}