fix(portals): hide provider placeholder metadata (N/A, -, 0) in details and cards (#1923)

* fix(portals): drop provider placeholder metadata at the mapping layer

Stalker and Xtream portals send "N/A", "-" or an unrated "0" score for
fields they have nothing for, and the detail templates rendered them as
data. Normalize them where provider rows are mapped: createStalkerInfo,
the shared VOD view-model adapters and the Xtream detail selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(portals): hide placeholder ratings on catalog cards

formatGridRating passed a provider "N/A" through as the badge text and
rendered an unrated zero as "0.0". Read the value through the shared
readProviderRating so both hide the badge and resolveGridRating reaches
the generic rating behind a placeholder rating_imdb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(xtream): keep placeholder ratings off the sparse VOD fallback page

A get_vod_info made only of placeholders is now stripped to sparse and
routed to the fallback page, which read the top-level catalog ratings
raw: "N/A" survived and a zero became "0.0". Read every candidate
through readProviderRating so the next usable rating wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-13 08:06:03 +02:00
1 parent 70e771ff27
commit ebb1ab3467
22 files changed
+770 -69

No files matched your search

+1
View File
@@ -103,6 +103,7 @@ export * from './lib/stalker-serial-details.interface';
export * from './lib/stalker-vod-details.interface';
// Unified VOD details (discriminated union + adapters)
export * from './lib/provider-metadata.util';
export * from './lib/vod-details-adapters';
export * from './lib/vod-details-item.interface';
@@ -0,0 +1,49 @@
import {
firstProviderText,
readProviderRating,
readProviderText,
} from './provider-metadata.util';
describe('readProviderText', () => {
it.each([undefined, null, '', ' ', 'N/A', 'n/a', ' N/a ', '-', ' - '])(
'reads %p as absent',
(value) => {
expect(readProviderText(value)).toBeUndefined();
}
);
it('trims real text and keeps a value that only contains a stand-in', () => {
expect(readProviderText(' Drama ')).toBe('Drama');
expect(readProviderText('Sci-Fi')).toBe('Sci-Fi');
expect(readProviderText('N/A Studios')).toBe('N/A Studios');
});
it('reads finite numbers as text and nothing else', () => {
expect(readProviderText(2011)).toBe('2011');
expect(readProviderText(0)).toBe('0');
expect(readProviderText(Number.NaN)).toBeUndefined();
expect(readProviderText({})).toBeUndefined();
});
});
describe('firstProviderText', () => {
it('skips placeholders and blanks to reach the usable fallback', () => {
expect(firstProviderText('N/A', '', ' ', '2011')).toBe('2011');
expect(firstProviderText('-', undefined)).toBeUndefined();
});
});
describe('readProviderRating', () => {
it.each(['0', '0.0', '0,0', 0, 'N/A', '-', ''])(
'reads %p as unrated',
(value) => {
expect(readProviderRating(value)).toBeUndefined();
}
);
it('keeps real scores as the provider wrote them', () => {
expect(readProviderRating('7.4')).toBe('7.4');
expect(readProviderRating('6,8')).toBe('6,8');
expect(readProviderRating(8)).toBe('8');
});
});
@@ -0,0 +1,59 @@
/**
* Readers for provider detail metadata (`get_vod_info`, Stalker VOD rows).
*
* Portals fill a field they have nothing for with a stand-in instead of
* leaving it empty — `"N/A"` (in any case) or a lone `"-"` — and a detail
* template that renders any truthy value then shows the stand-in as if it
* were data: a `⭐ N/A` chip, an "N/A" director, an "N/A" plot. The detail
* shell's contract is that missing data simply is not rendered, so these
* readers turn every stand-in into "absent" where provider rows are MAPPED,
* and templates keep plain truthiness guards instead of each learning the
* list.
*
* Keep the list to stand-ins providers were actually seen sending: every
* entry also erases a genuine value that happens to read the same.
*/
const PROVIDER_PLACEHOLDERS: ReadonlySet<string> = new Set(['n/a', '-']);
/**
* Trimmed provider text, or `undefined` when it is blank or a placeholder.
* Finite numbers are read as their text form; anything else is absent.
*/
export function readProviderText(value: unknown): string | undefined {
const text =
typeof value === 'string'
? value.trim()
: typeof value === 'number' && Number.isFinite(value)
? String(value)
: '';
return text && !PROVIDER_PLACEHOLDERS.has(text.toLowerCase())
? text
: undefined;
}
/**
* The first candidate that carries real text. `a ?? b` would stop at a
* placeholder (or a blank) and never reach the usable fallback behind it.
*/
export function firstProviderText(...values: unknown[]): string | undefined {
for (const value of values) {
const text = readProviderText(value);
if (text !== undefined) {
return text;
}
}
return undefined;
}
/**
* {@link readProviderText} for an IMDb / Kinopoisk score, where zero is a
* placeholder too: both scales start at 1, so a `"0"` / `"0.0"` is the
* panel's way of saying "unrated" (TMDB's `vote_average` of 0 is read the
* same way by the enrichment merge).
*/
export function readProviderRating(value: unknown): string | undefined {
const text = readProviderText(value);
return text !== undefined && Number(text.replace(',', '.')) === 0
? undefined
: text;
}
@@ -71,6 +71,116 @@ describe('normalized release year', () => {
});
});
describe('provider placeholders in the details view model', () => {
const PLACEHOLDER_FREE_FIELDS = [
'description',
'posterUrl',
'backdropUrl',
'year',
'genre',
'duration',
'country',
'director',
'actors',
'ratingImdb',
'ratingKinopoisk',
] as const;
it('renders none of the stand-ins a Stalker portal sends for empty fields', () => {
const meta = normalizeStalkerVod({
id: '1',
cmd: '',
info: {
name: 'Placeholder Film',
o_name: 'N/A',
movie_image: 'N/A',
description: 'N/A',
actors: 'n/a',
director: '-',
releasedate: 'N/A',
genre: ' N/A ',
rating_imdb: 'N/A',
rating_kinopoisk: '0',
},
});
// A placeholder original title must not shadow the real one
expect(meta.title).toBe('Placeholder Film');
for (const field of PLACEHOLDER_FREE_FIELDS) {
expect(meta[field]).toBeUndefined();
}
});
it('renders none of the stand-ins in an Xtream detail response', () => {
const meta = normalizeXtreamVod({
info: {
kinopoisk_url: '',
tmdb_id: 0,
name: 'N/A',
o_name: '',
cover_big: '-',
movie_image: 'N/A',
releasedate: 'N/A',
episode_run_time: 0,
youtube_trailer: '',
director: 'N/A',
actors: 'N/A',
cast: '-',
description: 'N/A',
plot: 'n/a',
age: '',
mpaa_rating: '',
rating_count_kinopoisk: 0,
country: 'N/A',
genre: '-',
backdrop_path: ['N/A'],
duration_secs: 0,
duration: 'N/A',
video: [],
audio: [],
bitrate: 0,
rating: 0,
rating_imdb: '0.0',
rating_kinopoisk: 'N/A',
},
movie_data: {
stream_id: 5,
name: 'Catalog Title',
added: '',
category_id: '1',
container_extension: 'mp4',
custom_sid: null,
direct_source: '',
},
});
expect(meta.title).toBe('Catalog Title');
for (const field of PLACEHOLDER_FREE_FIELDS) {
expect(meta[field]).toBeUndefined();
}
});
it('falls through a placeholder to the next usable Xtream field', () => {
const meta = normalizeXtreamVod({
info: {
name: 'Fallback Film',
description: 'N/A',
plot: 'A plot from the second field',
actors: '-',
cast: 'First Actor, Second Actor',
duration: 'N/A',
duration_secs: 5400,
rating_imdb: '7.2',
} as never,
});
expect(meta.description).toBe('A plot from the second field');
expect(meta.actors).toBe('First Actor, Second Actor');
expect(meta.duration).toBe('1h 30m');
expect(meta.ratingImdb).toBe('7.2');
});
});
describe('youtubeEmbedUrl', () => {
it('builds an embed URL from a plain video id (TMDB format)', () => {
expect(youtubeEmbedUrl('zAGVQLHvwOY')).toBe(
@@ -1,5 +1,10 @@
import { XtreamVodDetails, getXtreamVodInfo } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
import {
firstProviderText,
readProviderRating,
readProviderText,
} from './provider-metadata.util';
import { parseFacetYear } from './tmdb.interface';
import {
NormalizedVodMeta,
@@ -11,24 +16,28 @@ import {
/**
* Normalizes Xtream VOD details into a display-friendly format.
* Handles the various field name variations in Xtream API responses.
* Provider stand-ins ("N/A", "-", an unrated "0") read as absent — see
* `provider-metadata.util.ts`.
*/
export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
const info = getXtreamVodInfo(item);
const movieData = item?.movie_data;
return {
title: info?.name || movieData?.name || 'Unknown',
description: info?.description || info?.plot,
posterUrl: info?.movie_image || info?.cover_big,
backdropUrl: info?.backdrop_path?.[0],
year: extractYear(info?.releasedate),
genre: info?.genre,
duration: info?.duration || formatDuration(info?.duration_secs),
country: info?.country,
director: info?.director,
actors: info?.actors || info?.cast,
ratingImdb: info?.rating_imdb,
ratingKinopoisk: info?.rating_kinopoisk,
title: firstProviderText(info?.name, movieData?.name) ?? 'Unknown',
description: firstProviderText(info?.description, info?.plot),
posterUrl: firstProviderText(info?.movie_image, info?.cover_big),
backdropUrl: readProviderText(info?.backdrop_path?.[0]),
year: extractYear(readProviderText(info?.releasedate)),
genre: readProviderText(info?.genre),
duration:
readProviderText(info?.duration) ??
formatDuration(info?.duration_secs),
country: readProviderText(info?.country),
director: readProviderText(info?.director),
actors: firstProviderText(info?.actors, info?.cast),
ratingImdb: readProviderRating(info?.rating_imdb),
ratingKinopoisk: readProviderRating(info?.rating_kinopoisk),
youtubeTrailer: info?.youtube_trailer,
tmdbCast: info?.tmdb_cast,
tmdbDirectors: info?.tmdb_directors,
@@ -42,24 +51,26 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
* Normalizes Stalker VOD details into a display-friendly format.
* Stalker has fewer metadata fields than Xtream.
* Prefers o_name (original title) over name (often formatted/generic).
* Provider stand-ins ("N/A", "-", an unrated "0") read as absent — see
* `provider-metadata.util.ts`.
*/
export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta {
const info = item?.info;
return {
title: info?.o_name || info?.name || 'Unknown',
description: info?.description,
posterUrl: info?.movie_image,
title: firstProviderText(info?.o_name, info?.name) ?? 'Unknown',
description: readProviderText(info?.description),
posterUrl: readProviderText(info?.movie_image),
// Stalker portals never provide a backdrop; TMDB enrichment can
backdropUrl: info?.tmdb_backdrop,
year: extractYear(info?.releasedate),
genre: info?.genre,
year: extractYear(readProviderText(info?.releasedate)),
genre: readProviderText(info?.genre),
duration: undefined, // Stalker doesn't provide duration
country: undefined, // Stalker doesn't provide country
director: info?.director,
actors: info?.actors,
ratingImdb: info?.rating_imdb,
ratingKinopoisk: info?.rating_kinopoisk,
director: readProviderText(info?.director),
actors: readProviderText(info?.actors),
ratingImdb: readProviderRating(info?.rating_imdb),
ratingKinopoisk: readProviderRating(info?.rating_kinopoisk),
// Stalker portals provide no trailers; TMDB enrichment can
youtubeTrailer: info?.tmdb_trailer,
tmdbCast: info?.tmdb_cast,