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:
4gray committed 2026-10-13 09:14:50 +02:00
commit de87c1e4d3
78 files changed
+4367 -276

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';
@@ -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,