mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
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:
1 parent
70e771ff27
commit
ebb1ab3467
22 files changed
+770
-69
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: portals
|
||||
---
|
||||
|
||||
Movie and series details and catalog cards from Stalker and Xtream portals no longer show "N/A", "-" or a zero rating in place of missing information. Fields the provider left empty are simply hidden, and TMDB can now fill in the rating and release year for them.
|
||||
@@ -157,6 +157,14 @@ Contracts:
|
||||
- Hosts provide hero chips/meta/actions as `*appDetailTags` / `*appDetailMeta` /
|
||||
`*appDetailActions` templates; the shell stamps them into the hero and again
|
||||
into the About block. Degradation stays "missing → not rendered".
|
||||
- Provider stand-ins count as missing: `N/A` (any case), a lone `-`, and a
|
||||
`0` IMDb/Kinopoisk score are dropped where provider rows are mapped, by
|
||||
`readProviderText` / `readProviderRating`
|
||||
(`libs/shared/interfaces/src/lib/provider-metadata.util.ts`) — in
|
||||
`createStalkerInfo()`, the `vod-details-adapters.ts` normalizers and the
|
||||
Xtream store's detail selection. Templates keep plain `@if` guards; do not
|
||||
teach them the placeholder list. Widen the list only with evidence of a
|
||||
provider sending the new stand-in.
|
||||
- The shell never wraps the `[detail-player]` slot in a conditional; the
|
||||
host's `@if (inlinePlayback())` is the only creator/destroyer of the
|
||||
player subtree, so shell state changes cannot recreate the `<video>`.
|
||||
|
||||
@@ -422,6 +422,17 @@ Failure-handling rule:
|
||||
not `undefined` collections or renderer exceptions. The workspace Stalker
|
||||
context panel and live layout rely on this guarantee.
|
||||
|
||||
Detail metadata rule:
|
||||
|
||||
- `createStalkerInfo()` (`stalker-vod.utils.ts`) is the mapping both detail
|
||||
templates read: the series view binds `info` directly and the shared VOD
|
||||
view reads it through `normalizeStalkerVod()`. Each field takes the first
|
||||
usable candidate, and portal stand-ins (`N/A`, `-`, an unrated `0` score)
|
||||
become `''` there — see the placeholder rule in
|
||||
[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.
|
||||
|
||||
## Stalker Identity Policy
|
||||
|
||||
Full Stalker/Ministra portal authentication defaults to MAC-only identity. The
|
||||
|
||||
@@ -344,6 +344,12 @@ resolves. An unresolved source exposes no actions. Playback and download
|
||||
descriptors also fall back to the catalog title and poster after `info` and
|
||||
`movie_data`.
|
||||
|
||||
Provider stand-ins in `get_vod_info` / `get_series_info` (`N/A`, `-`, an
|
||||
unrated `0` score) are emptied before the response becomes the store
|
||||
selection (`stores/xtream-detail-metadata.ts`), since both detail routes bind
|
||||
`info` fields directly. A `get_vod_info` response made only of stand-ins
|
||||
therefore counts as sparse and gets the fallback page above.
|
||||
|
||||
## Catch-Up Playback URLs
|
||||
|
||||
Xtream-compatible portals differ on archive playback URL shape. IPTVnator
|
||||
|
||||
@@ -26,6 +26,24 @@ describe('grid list rating helpers', () => {
|
||||
).toBe('7.2');
|
||||
});
|
||||
|
||||
it('shows no badge for a provider stand-in or an unrated zero', () => {
|
||||
for (const value of ['N/A', 'n/a', '-', '0', '0.0', 0]) {
|
||||
expect(formatGridRating(value)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the generic rating when imdb rating is a stand-in', () => {
|
||||
expect(
|
||||
resolveGridRating({
|
||||
rating: '6.5',
|
||||
rating_imdb: 'N/A',
|
||||
})
|
||||
).toBe('6.5');
|
||||
expect(resolveGridRating({ rating: '0', rating_imdb: '-' })).toBe(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic rating when imdb rating is blank', () => {
|
||||
expect(
|
||||
resolveGridRating({
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
|
||||
import { readProviderRating } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PortalWatchState,
|
||||
getXtreamCatchupDays,
|
||||
@@ -49,22 +50,19 @@ interface GridListItem {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card rating badge text. Provider stand-ins (`"N/A"`, `"-"`) and an unrated
|
||||
* zero read as no rating — see `readProviderRating` — so the badge is hidden
|
||||
* and {@link resolveGridRating} can reach the generic `rating` behind them.
|
||||
*/
|
||||
export function formatGridRating(value: unknown): string | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
const text = readProviderRating(value);
|
||||
if (text === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const numericRating = Number.parseFloat(trimmed);
|
||||
return Number.isFinite(numericRating) ? numericRating.toFixed(1) : trimmed;
|
||||
const numericRating = Number.parseFloat(text);
|
||||
return Number.isFinite(numericRating) ? numericRating.toFixed(1) : text;
|
||||
}
|
||||
|
||||
export function resolveGridRating(
|
||||
|
||||
@@ -342,6 +342,82 @@ describe('stalker-vod.utils regressions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider placeholders in Stalker VOD info', () => {
|
||||
// The series view renders `info` directly and the shared VOD template
|
||||
// renders it through the view-model adapter, so the stand-ins have to
|
||||
// be gone from `info` itself — not just from one template's guards.
|
||||
const PLACEHOLDER_ROW = {
|
||||
id: '31',
|
||||
cmd: '/media/file_31.mpg',
|
||||
name: 'Placeholder Movie',
|
||||
o_name: 'N/A',
|
||||
screenshot_uri: 'N/A',
|
||||
description: 'N/A',
|
||||
actors: 'n/a',
|
||||
director: '-',
|
||||
year: 'N/A',
|
||||
genres_str: ' N/A ',
|
||||
rating_imdb: 'N/A',
|
||||
rating_kinopoisk: '0',
|
||||
};
|
||||
|
||||
it('maps every stand-in to the empty value the info shape uses for absent', () => {
|
||||
const info = createStalkerInfo(PLACEHOLDER_ROW);
|
||||
|
||||
expect(info).toEqual({
|
||||
name: 'Placeholder Movie',
|
||||
o_name: undefined,
|
||||
movie_image: '',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the series view free of stand-ins through re-normalization', () => {
|
||||
const displayed = normalizeStalkerVodDetailsItem(
|
||||
normalizeStalkerVodDetailsItem({
|
||||
...PLACEHOLDER_ROW,
|
||||
is_series: 1,
|
||||
})
|
||||
);
|
||||
|
||||
expect(displayed.info.releasedate).toBe('');
|
||||
expect(displayed.info.rating_imdb).toBe('');
|
||||
expect(displayed.info.rating_kinopoisk).toBe('');
|
||||
expect(displayed.info.description).toBe('');
|
||||
});
|
||||
|
||||
it('falls through a placeholder to the usable value behind it', () => {
|
||||
const info = createStalkerInfo({
|
||||
id: '32',
|
||||
year: '2011',
|
||||
genres_str: 'Drama',
|
||||
description: 'Plot from the catalog row',
|
||||
rating_imdb: 7.1,
|
||||
info: {
|
||||
name: 'N/A',
|
||||
o_name: 'Original Title',
|
||||
releasedate: 'N/A',
|
||||
genre: '-',
|
||||
description: 'N/A',
|
||||
rating_imdb: '0.0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(info.name).toBe('Original Title');
|
||||
expect(info.o_name).toBe('Original Title');
|
||||
expect(info.releasedate).toBe('2011');
|
||||
expect(info.genre).toBe('Drama');
|
||||
expect(info.description).toBe('Plot from the catalog row');
|
||||
expect(info.rating_imdb).toBe('7.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('firstNonBlankStalkerId', () => {
|
||||
it('skips a blank candidate instead of treating it as set', () => {
|
||||
expect(firstNonBlankStalkerId('', ' ', '77')).toBe('77');
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Observable } from 'rxjs';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
createStalkerVodItem,
|
||||
firstProviderText,
|
||||
isStalkerSeriesFlag,
|
||||
isStalkerSeriesItem,
|
||||
readProviderRating,
|
||||
StalkerVodDetails,
|
||||
StalkerVodInfo,
|
||||
VodDetailsItem,
|
||||
@@ -146,36 +148,46 @@ function toStringOrFallback(value: unknown, fallback = ''): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function toOptionalString(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw portal row (or an already-built selection, on re-normalization)
|
||||
* to the `info` both Stalker detail templates render.
|
||||
*
|
||||
* Portals send `"N/A"`, `"-"` or an unrated `"0"` score where they have
|
||||
* nothing, so every field reads the first USABLE candidate and falls back to
|
||||
* `''`, which is how this shape spells "absent". The series view binds
|
||||
* `info` directly, and TMDB enrichment keeps any non-empty provider rating
|
||||
* and release date, so a stand-in surviving this point would be rendered
|
||||
* AND would block the TMDB value that should have replaced it.
|
||||
*/
|
||||
export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
|
||||
const info = item.info ?? {};
|
||||
const text = (...values: unknown[]) => firstProviderText(...values) ?? '';
|
||||
const rating = (primary: unknown, fallback: unknown) =>
|
||||
readProviderRating(primary) ?? readProviderRating(fallback) ?? '';
|
||||
|
||||
return {
|
||||
name: toStringOrFallback(
|
||||
info.name ?? info.o_name ?? item.o_name ?? item.name ?? item.title,
|
||||
'Unknown'
|
||||
),
|
||||
o_name: toOptionalString(info.o_name ?? item.o_name),
|
||||
movie_image: toStringOrFallback(
|
||||
info.movie_image ?? item.cover ?? item.screenshot_uri ?? item.logo
|
||||
),
|
||||
description: toStringOrFallback(info.description ?? item.description),
|
||||
actors: toStringOrFallback(info.actors ?? item.actors),
|
||||
director: toStringOrFallback(info.director ?? item.director),
|
||||
releasedate: toStringOrFallback(
|
||||
info.releasedate ?? item.releasedate ?? item.year
|
||||
),
|
||||
genre: toStringOrFallback(info.genre ?? item.genre ?? item.genres_str),
|
||||
rating_imdb: toStringOrFallback(info.rating_imdb ?? item.rating_imdb),
|
||||
rating_kinopoisk: toStringOrFallback(
|
||||
info.rating_kinopoisk ?? item.rating_kinopoisk
|
||||
name:
|
||||
firstProviderText(
|
||||
info.name,
|
||||
info.o_name,
|
||||
item.o_name,
|
||||
item.name,
|
||||
item.title
|
||||
) ?? 'Unknown',
|
||||
o_name: firstProviderText(info.o_name, item.o_name),
|
||||
movie_image: text(
|
||||
info.movie_image,
|
||||
item.cover,
|
||||
item.screenshot_uri,
|
||||
item.logo
|
||||
),
|
||||
description: text(info.description, item.description),
|
||||
actors: text(info.actors, item.actors),
|
||||
director: text(info.director, item.director),
|
||||
releasedate: text(info.releasedate, item.releasedate, item.year),
|
||||
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 } : {}),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { XtreamSerieInfo, XtreamVodInfo } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
stripXtreamSerieInfoPlaceholders,
|
||||
stripXtreamVodInfoPlaceholders,
|
||||
} from './xtream-detail-metadata';
|
||||
|
||||
describe('Xtream detail placeholders', () => {
|
||||
it('empties every stand-in the VOD detail route would render', () => {
|
||||
const info = stripXtreamVodInfoPlaceholders({
|
||||
name: 'Placeholder Film',
|
||||
o_name: 'N/A',
|
||||
movie_image: 'N/A',
|
||||
cover_big: '-',
|
||||
releasedate: 'N/A',
|
||||
genre: 'n/a',
|
||||
duration: 'N/A',
|
||||
country: '-',
|
||||
director: 'N/A',
|
||||
actors: 'N/A',
|
||||
cast: '-',
|
||||
description: 'N/A',
|
||||
plot: ' N/A ',
|
||||
rating_imdb: 'N/A',
|
||||
rating_kinopoisk: '0',
|
||||
backdrop_path: ['N/A', 'https://img.example/backdrop.jpg'],
|
||||
duration_secs: 0,
|
||||
} as unknown as XtreamVodInfo);
|
||||
|
||||
expect(info).toEqual({
|
||||
name: 'Placeholder Film',
|
||||
o_name: '',
|
||||
movie_image: '',
|
||||
cover_big: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
duration: '',
|
||||
country: '',
|
||||
director: '',
|
||||
actors: '',
|
||||
cast: '',
|
||||
description: '',
|
||||
plot: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
backdrop_path: ['https://img.example/backdrop.jpg'],
|
||||
duration_secs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves real values, absent fields and non-text fields untouched', () => {
|
||||
const source = {
|
||||
name: 'Real Film',
|
||||
releasedate: '2019-04-05',
|
||||
rating_imdb: '7.4',
|
||||
rating: 0,
|
||||
tmdb_id: 123,
|
||||
} as unknown as XtreamVodInfo;
|
||||
|
||||
expect(stripXtreamVodInfoPlaceholders(source)).toEqual(source);
|
||||
});
|
||||
|
||||
it('empties every stand-in the series detail route would render', () => {
|
||||
const info = stripXtreamSerieInfoPlaceholders({
|
||||
name: 'Placeholder Series',
|
||||
cover: 'N/A',
|
||||
plot: 'N/A',
|
||||
cast: '-',
|
||||
director: 'n/a',
|
||||
genre: 'N/A',
|
||||
releaseDate: 'N/A',
|
||||
rating: '0',
|
||||
rating_5based: 0,
|
||||
episode_run_time: 'N/A',
|
||||
backdrop_path: ['-'],
|
||||
} as unknown as XtreamSerieInfo);
|
||||
|
||||
expect(info).toEqual({
|
||||
name: 'Placeholder Series',
|
||||
cover: '',
|
||||
plot: '',
|
||||
cast: '',
|
||||
director: '',
|
||||
genre: '',
|
||||
releaseDate: '',
|
||||
rating: '',
|
||||
rating_5based: 0,
|
||||
episode_run_time: '',
|
||||
backdrop_path: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a malformed info payload through unchanged', () => {
|
||||
expect(stripXtreamSerieInfoPlaceholders(undefined as never)).toBe(
|
||||
undefined
|
||||
);
|
||||
const list = [] as unknown as XtreamSerieInfo;
|
||||
expect(stripXtreamSerieInfoPlaceholders(list)).toBe(list);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
readProviderRating,
|
||||
readProviderText,
|
||||
XtreamSerieInfo,
|
||||
XtreamVodInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const VOD_TEXT_FIELDS = [
|
||||
'name',
|
||||
'o_name',
|
||||
'movie_image',
|
||||
'cover_big',
|
||||
'releasedate',
|
||||
'genre',
|
||||
'duration',
|
||||
'country',
|
||||
'director',
|
||||
'actors',
|
||||
'cast',
|
||||
'description',
|
||||
'plot',
|
||||
] as const satisfies readonly (keyof XtreamVodInfo)[];
|
||||
const VOD_RATING_FIELDS = [
|
||||
'rating_imdb',
|
||||
'rating_kinopoisk',
|
||||
] as const satisfies readonly (keyof XtreamVodInfo)[];
|
||||
|
||||
const SERIE_TEXT_FIELDS = [
|
||||
'name',
|
||||
'cover',
|
||||
'plot',
|
||||
'cast',
|
||||
'director',
|
||||
'genre',
|
||||
'releaseDate',
|
||||
'episode_run_time',
|
||||
] as const satisfies readonly (keyof XtreamSerieInfo)[];
|
||||
const SERIE_RATING_FIELDS = [
|
||||
'rating',
|
||||
] as const satisfies readonly (keyof XtreamSerieInfo)[];
|
||||
|
||||
/**
|
||||
* Removes provider stand-ins (`"N/A"`, `"-"`, an unrated `"0"` score) from
|
||||
* an Xtream detail response before it becomes the store selection.
|
||||
*
|
||||
* The VOD and series detail routes bind these `info` fields directly, and
|
||||
* TMDB enrichment keeps any non-empty provider rating and release date — a
|
||||
* stand-in left here is both rendered and shields the TMDB value that should
|
||||
* have replaced it. Only string fields that are present are rewritten, to
|
||||
* `''` (how these shapes spell "absent"), so the response keeps its shape.
|
||||
*/
|
||||
export function stripXtreamVodInfoPlaceholders(
|
||||
info: XtreamVodInfo
|
||||
): XtreamVodInfo {
|
||||
return stripPlaceholders(info, VOD_TEXT_FIELDS, VOD_RATING_FIELDS);
|
||||
}
|
||||
|
||||
/** {@link stripXtreamVodInfoPlaceholders} for `get_series_info`. */
|
||||
export function stripXtreamSerieInfoPlaceholders(
|
||||
info: XtreamSerieInfo
|
||||
): XtreamSerieInfo {
|
||||
return stripPlaceholders(info, SERIE_TEXT_FIELDS, SERIE_RATING_FIELDS);
|
||||
}
|
||||
|
||||
function stripPlaceholders<T extends { backdrop_path?: unknown }>(
|
||||
info: T,
|
||||
textFields: readonly (keyof T)[],
|
||||
ratingFields: readonly (keyof T)[]
|
||||
): T {
|
||||
// Broken panels answer with `[]` or nothing; leave those to the callers'
|
||||
// existing sparse-response handling
|
||||
if (!info || typeof info !== 'object' || Array.isArray(info)) {
|
||||
return info;
|
||||
}
|
||||
|
||||
const stripped: Record<keyof T, unknown> = { ...info };
|
||||
const rewrite = (
|
||||
fields: readonly (keyof T)[],
|
||||
read: (value: unknown) => string | undefined
|
||||
) => {
|
||||
for (const field of fields) {
|
||||
const value = info[field];
|
||||
if (typeof value === 'string') {
|
||||
stripped[field] = read(value) ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
rewrite(textFields, readProviderText);
|
||||
rewrite(ratingFields, readProviderRating);
|
||||
|
||||
if (Array.isArray(info.backdrop_path)) {
|
||||
stripped.backdrop_path = info.backdrop_path.filter(
|
||||
(url) => readProviderText(url) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
return stripped as T;
|
||||
}
|
||||
@@ -28,6 +28,28 @@ describe('Xtream VOD selection helpers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('strips provider stand-ins from the detail info it publishes', () => {
|
||||
const selection = buildXtreamVodSelection(
|
||||
{
|
||||
info: {
|
||||
name: 'Placeholder Film',
|
||||
releasedate: 'N/A',
|
||||
rating_imdb: 'N/A',
|
||||
director: '-',
|
||||
},
|
||||
} as unknown as Parameters<typeof buildXtreamVodSelection>[0],
|
||||
undefined,
|
||||
42
|
||||
);
|
||||
|
||||
expect(selection.info).toEqual({
|
||||
name: 'Placeholder Film',
|
||||
releasedate: '',
|
||||
rating_imdb: '',
|
||||
director: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a complete detail playback pair ahead of a catalog collision', () => {
|
||||
const catalogItem = {
|
||||
stream_id: 42,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
getXtreamVodInfo,
|
||||
XtreamVodDetails,
|
||||
XtreamVodStream,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
resolveXtreamVodPlaybackSource,
|
||||
XtreamVodPlaybackItem,
|
||||
} from '../services/xtream-vod-playback-source';
|
||||
import { stripXtreamVodInfoPlaceholders } from './xtream-detail-metadata';
|
||||
|
||||
export type XtreamVodSelection = XtreamVodDetails & {
|
||||
[key: string]: unknown;
|
||||
@@ -35,11 +37,13 @@ export function buildXtreamVodSelection(
|
||||
resolveXtreamVodPlaybackSource(
|
||||
catalogItem as unknown as XtreamVodPlaybackItem
|
||||
);
|
||||
const info = getXtreamVodInfo(vodDetails);
|
||||
|
||||
return {
|
||||
...catalogItem,
|
||||
...recoveredCatalogItem,
|
||||
...vodDetails,
|
||||
...(info ? { info: stripXtreamVodInfoPlaceholders(info) } : {}),
|
||||
stream_id: playbackSource?.streamId ?? Number(vodId),
|
||||
container_extension: playbackSource?.containerExtension ?? null,
|
||||
xtream_id:
|
||||
|
||||
@@ -345,6 +345,39 @@ describe('XtreamStore VOD details recovery', () => {
|
||||
expect(store.isLoadingDetails()).toBe(false);
|
||||
});
|
||||
|
||||
it('publishes series details without provider stand-ins', async () => {
|
||||
const xtreamApiService = {
|
||||
getSeriesInfo: jest.fn().mockResolvedValue({
|
||||
info: {
|
||||
name: 'Placeholder Series',
|
||||
plot: 'N/A',
|
||||
rating: '0',
|
||||
releaseDate: 'N/A',
|
||||
},
|
||||
seasons: [],
|
||||
episodes: {},
|
||||
}),
|
||||
getVodInfo: jest.fn(),
|
||||
getVodStream: jest.fn(),
|
||||
};
|
||||
const store = configureStore(xtreamApiService);
|
||||
|
||||
store.fetchSerialDetailsWithMetadata({
|
||||
categoryId: 7,
|
||||
serialId: '99',
|
||||
});
|
||||
await waitForCondition(() => store.selectedItem() !== null);
|
||||
|
||||
expect(
|
||||
(store.selectedItem() as { info: Record<string, unknown> }).info
|
||||
).toEqual({
|
||||
name: 'Placeholder Series',
|
||||
plot: '',
|
||||
rating: '',
|
||||
releaseDate: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the previous series while a reused-route request is pending or fails', async () => {
|
||||
const nextDetails = createDeferred<never>();
|
||||
const xtreamApiService = {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
createXtreamDetailsRequestGuard,
|
||||
resolveXtreamVodDetailsSelection,
|
||||
} from './xtream-details-request';
|
||||
import { stripXtreamSerieInfoPlaceholders } from './xtream-detail-metadata';
|
||||
|
||||
/** Facade composing the Xtream feature stores and cross-feature workflows. */
|
||||
export const XtreamStore = signalStore(
|
||||
@@ -255,6 +256,9 @@ export const XtreamStore = signalStore(
|
||||
store.setSelectedCategory(params.categoryId);
|
||||
store.setSelectedItem({
|
||||
...serialDetails,
|
||||
info: stripXtreamSerieInfoPlaceholders(
|
||||
serialDetails?.info
|
||||
),
|
||||
series_id: params.serialId,
|
||||
});
|
||||
store.resetTmdbSeasonMetadata(params.serialId);
|
||||
|
||||
@@ -174,4 +174,37 @@ describe('vod-details-fallback.util', () => {
|
||||
year: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeholder ratings', () => {
|
||||
// Placeholder-only `info` is stripped upstream, which routes the
|
||||
// movie here — the top-level catalog ratings must not resurface as
|
||||
// "N/A" or a "0.0" score on this page instead.
|
||||
const ratingOf = (
|
||||
detail: Record<string, unknown>,
|
||||
catalog: Record<string, unknown>
|
||||
) =>
|
||||
buildXtreamVodFallbackViewModel({
|
||||
vodDetails: { info: [], ...detail } as XtreamVodDetails,
|
||||
catalogItem: catalog as Partial<XtreamVodStream>,
|
||||
vodId: 31,
|
||||
})?.rating;
|
||||
|
||||
it('skips stand-ins and unrated zeros to the next usable rating', () => {
|
||||
expect(
|
||||
ratingOf(
|
||||
{ rating_imdb: 'N/A', rating: 0 },
|
||||
{ rating_imdb: '-', rating: '7.25' }
|
||||
)
|
||||
).toBe('7.25');
|
||||
});
|
||||
|
||||
it('states no rating when every candidate is a stand-in', () => {
|
||||
expect(
|
||||
ratingOf(
|
||||
{ rating_imdb: 'N/A', rating: '0' },
|
||||
{ rating_imdb: '0.0', rating: 0 }
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
XtreamVodInfo,
|
||||
XtreamVodStream,
|
||||
getXtreamVodInfo,
|
||||
readProviderRating,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
type XtreamCategoryContext = {
|
||||
@@ -117,9 +118,9 @@ export function buildXtreamVodFallbackViewModel(
|
||||
sanitizeText(info?.cover_big) ??
|
||||
undefined;
|
||||
const rating =
|
||||
sanitizeText(detailItem?.rating_imdb) ??
|
||||
formatRating(detailItem?.rating_imdb) ??
|
||||
formatRating(detailItem?.rating) ??
|
||||
sanitizeText(catalogItem?.rating_imdb) ??
|
||||
formatRating(catalogItem?.rating_imdb) ??
|
||||
formatRating(catalogItem?.rating);
|
||||
const added = formatUnixDate(
|
||||
movieData?.added ??
|
||||
@@ -200,16 +201,16 @@ function numericValue(value: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider stand-ins and an unrated zero read as no rating, so the next
|
||||
* candidate is reached — see `readProviderRating`.
|
||||
*/
|
||||
function formatRating(value: unknown): string | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toFixed(1);
|
||||
const text = readProviderRating(value);
|
||||
if (text === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return typeof value === 'number' ? value.toFixed(1) : text;
|
||||
}
|
||||
|
||||
function extractYear(value: string | undefined): string | undefined {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -166,18 +166,20 @@ describe('VodDetailsComponent offline playback', () => {
|
||||
};
|
||||
|
||||
const render = async ({
|
||||
item = STALKER_VOD,
|
||||
playbackPosition = null,
|
||||
externalPlayback = null,
|
||||
providerOnly = false,
|
||||
isWatched = false,
|
||||
}: {
|
||||
item?: VodDetailsItem;
|
||||
playbackPosition?: number | null;
|
||||
externalPlayback?: ExternalPlayerSession | null;
|
||||
providerOnly?: boolean;
|
||||
isWatched?: boolean;
|
||||
} = {}) => {
|
||||
fixture = TestBed.createComponent(VodDetailsComponent);
|
||||
fixture.componentRef.setInput('item', STALKER_VOD);
|
||||
fixture.componentRef.setInput('item', item);
|
||||
fixture.componentRef.setInput(
|
||||
'playbackSessionKey',
|
||||
'stalker-host-owned-key'
|
||||
@@ -378,6 +380,46 @@ describe('VodDetailsComponent offline playback', () => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('renders no provider stand-ins in the hero or the About block', async () => {
|
||||
await render({
|
||||
item: createStalkerVodItem(
|
||||
{
|
||||
id: '43',
|
||||
cmd: '/media/file_43.mpg',
|
||||
info: {
|
||||
name: 'Placeholder Movie',
|
||||
o_name: 'N/A',
|
||||
movie_image: 'N/A',
|
||||
description: 'N/A',
|
||||
actors: 'N/A',
|
||||
director: 'N/A',
|
||||
releasedate: 'N/A',
|
||||
genre: '-',
|
||||
rating_imdb: 'N/A',
|
||||
rating_kinopoisk: '0',
|
||||
},
|
||||
},
|
||||
'stalker-1'
|
||||
),
|
||||
});
|
||||
// Watch state stamps the same templates into the About block
|
||||
fixture.componentRef.setInput('inlinePlayback', {
|
||||
streamUrl: 'https://portal.example/movie.mp4',
|
||||
title: 'Placeholder Movie',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Placeholder Movie');
|
||||
expect(text).not.toMatch(/N\/A/i);
|
||||
expect(text).not.toContain('⭐');
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'.details__tag:not(.details__tag--clickable), .details__meta-item'
|
||||
).length
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('renders Offline and plays a completed download from the primary action', async () => {
|
||||
completeDownload();
|
||||
await render();
|
||||
|
||||
Reference in new issue
Block a user