mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
Merge branch 'master' of https://github.com/4gray/iptvnator into claude/server-storage-pwa
# Conflicts: # docs/architecture/pwa-self-hosted.md
This commit is contained in:
commit
de87c1e4d3
78 files changed
+4367
-276
No files matched your search
@@ -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';
|
||||
|
||||
|
||||
@@ -62,6 +62,12 @@ export interface XtreamBackupRecentlyViewedItem {
|
||||
contentType: XtreamBackupContentType;
|
||||
xtreamId: number;
|
||||
viewedAt: string;
|
||||
/**
|
||||
* Artwork the browser build saved with this view, shown ahead of the
|
||||
* title snapshot's. Optional; the desktop build neither writes nor
|
||||
* reads it.
|
||||
*/
|
||||
backdropUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +82,17 @@ export interface XtreamBackupSourcePin {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A favorite or recently viewed title as the browser build last displayed it,
|
||||
* so the restored lists show titles and artwork before the provider catalog
|
||||
* is fetched again. Every other field is the item exactly as it was stored.
|
||||
*/
|
||||
export interface XtreamBackupCollectionItem {
|
||||
type: XtreamBackupContentType;
|
||||
xtream_id: number;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
export interface XtreamBackupUserState {
|
||||
hiddenCategories: XtreamBackupHiddenCategory[];
|
||||
favorites: XtreamBackupFavoriteItem[];
|
||||
@@ -83,6 +100,12 @@ export interface XtreamBackupUserState {
|
||||
playbackPositions: PlaybackPositionData[];
|
||||
/** Optional: absent in archives written before multi-source existed. */
|
||||
sourcePins?: XtreamBackupSourcePin[];
|
||||
/**
|
||||
* Optional and written only by the browser build, which keeps no catalog
|
||||
* between sessions. A cache, not user state: restore merges it into the
|
||||
* playlist's snapshots, and the desktop build ignores it.
|
||||
*/
|
||||
collectionItems?: XtreamBackupCollectionItem[];
|
||||
/**
|
||||
* Parental-lock categories, same shape as `hiddenCategories`. Optional
|
||||
* like `sourcePins`: absence means the archive predates the lock and
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user