mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(stalker): keep TMDB genre and country chips on Stalker series (#1930)
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:
1 parent
8bf92f9d65
commit
294d630156
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.
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+64
@@ -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;
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user