mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -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
@@ -0,0 +1,80 @@
|
||||
import { SQL } from 'drizzle-orm';
|
||||
import { buildPlaylistRow } from './app-playlist-codec';
|
||||
import { playlistConflictUpdate } from './app-playlist-conflict';
|
||||
import type { NewPlaylist } from './schema';
|
||||
|
||||
function rowOf(document: Record<string, unknown>): NewPlaylist {
|
||||
const row = buildPlaylistRow(document);
|
||||
if (!row) {
|
||||
throw new Error('Expected the document to produce a row');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Every optional column of a document that only has an id, title and URL. */
|
||||
const CLEARED_COLUMNS = {
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
password: null,
|
||||
lastUpdated: null,
|
||||
userAgent: null,
|
||||
origin: null,
|
||||
referrer: null,
|
||||
filePath: null,
|
||||
epgUrls: null,
|
||||
detectedEpgUrls: null,
|
||||
manualEpgUrls: null,
|
||||
disabledEpgUrls: null,
|
||||
macAddress: null,
|
||||
portalUrl: null,
|
||||
count: null,
|
||||
importDate: null,
|
||||
updateDate: null,
|
||||
position: null,
|
||||
favorites: null,
|
||||
recentlyViewed: null,
|
||||
};
|
||||
|
||||
describe('playlistConflictUpdate', () => {
|
||||
const document = {
|
||||
_id: 'm3u-1',
|
||||
title: 'Family',
|
||||
url: 'https://example.test/list.m3u',
|
||||
userAgent: '',
|
||||
referrer: ' ',
|
||||
};
|
||||
|
||||
it('sets every optional column the document leaves empty to NULL', () => {
|
||||
const row = rowOf(document);
|
||||
|
||||
expect(playlistConflictUpdate(row, document)).toMatchObject({
|
||||
...CLEARED_COLUMNS,
|
||||
url: 'https://example.test/list.m3u',
|
||||
});
|
||||
});
|
||||
|
||||
it('never clears the creation date or the columns every row carries', () => {
|
||||
const row = rowOf(document);
|
||||
const update = playlistConflictUpdate(row, document);
|
||||
|
||||
expect(update).not.toHaveProperty('dateCreated');
|
||||
expect(update).toMatchObject({
|
||||
id: 'm3u-1',
|
||||
name: 'Family',
|
||||
type: 'm3u-url',
|
||||
autoRefresh: false,
|
||||
lastUsage: row.lastUsage,
|
||||
});
|
||||
// A clockless document keeps a stored panel clock through SQL.
|
||||
expect(update.payload).toBeInstanceOf(SQL);
|
||||
});
|
||||
|
||||
it('clears the same columns when the document carries its own clock', () => {
|
||||
const withClock = { ...document, serverTimezone: 'UTC' };
|
||||
const row = rowOf(withClock);
|
||||
const update = playlistConflictUpdate(row, withClock);
|
||||
|
||||
expect(update).toMatchObject(CLEARED_COLUMNS);
|
||||
expect(update.payload).toBe(row.payload);
|
||||
});
|
||||
});
|
||||
@@ -14,22 +14,70 @@ export type PlaylistConflictUpdate = {
|
||||
const stored = schema.playlists;
|
||||
|
||||
/**
|
||||
* The `DO UPDATE` half of an app-playlist upsert. A full upsert is built
|
||||
* from a snapshot the caller read earlier; when that snapshot carries no
|
||||
* panel clock while the stored row does — `setPlaylistServerTimezone`
|
||||
* landed in between — the stored clock is carried over as long as the row
|
||||
* still points at the same panel, so a favorites, recent-items or metadata
|
||||
* write cannot hand a clockless payload back to the past. A snapshot that
|
||||
* carries its own clock, or moves the source, wins as is. Nested CASE, not
|
||||
* AND: SQLite may reorder AND terms, and the json_* readers raise on a
|
||||
* malformed payload unless json_valid ran first.
|
||||
* Columns a whole-document upsert must rewrite even when the document no
|
||||
* longer has a value: the codec maps empty values to `undefined`, which an
|
||||
* `ON CONFLICT DO UPDATE` skips, so a cleared field would keep its old
|
||||
* column value and `parseAppPlaylist` would bring it back. `dateCreated`
|
||||
* belongs to the first insert, and `name`, `type`, `autoRefresh`,
|
||||
* `payload` and `lastUsage` always carry a value.
|
||||
*/
|
||||
const REPLACEABLE_COLUMNS = [
|
||||
'serverUrl',
|
||||
'username',
|
||||
'password',
|
||||
'lastUpdated',
|
||||
'userAgent',
|
||||
'origin',
|
||||
'referrer',
|
||||
'filePath',
|
||||
'epgUrls',
|
||||
'detectedEpgUrls',
|
||||
'manualEpgUrls',
|
||||
'disabledEpgUrls',
|
||||
'macAddress',
|
||||
'url',
|
||||
'portalUrl',
|
||||
'count',
|
||||
'importDate',
|
||||
'updateDate',
|
||||
'position',
|
||||
'favorites',
|
||||
'recentlyViewed',
|
||||
] as const satisfies readonly (keyof schema.NewPlaylist)[];
|
||||
|
||||
function withClearedColumns(row: schema.NewPlaylist): schema.NewPlaylist {
|
||||
const cleared: Partial<Record<(typeof REPLACEABLE_COLUMNS)[number], null>> =
|
||||
{};
|
||||
for (const column of REPLACEABLE_COLUMNS) {
|
||||
if (row[column] === undefined) {
|
||||
cleared[column] = null;
|
||||
}
|
||||
}
|
||||
return { ...row, ...cleared };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `DO UPDATE` half of an app-playlist upsert. The document replaces
|
||||
* the stored one, so every optional column it leaves empty is set to NULL
|
||||
* rather than skipped (see REPLACEABLE_COLUMNS).
|
||||
*
|
||||
* A full upsert is built from a snapshot the caller read earlier; when
|
||||
* that snapshot carries no panel clock while the stored row does —
|
||||
* `setPlaylistServerTimezone` landed in between — the stored clock is
|
||||
* carried over as long as the row still points at the same panel, so a
|
||||
* favorites, recent-items or metadata write cannot hand a clockless
|
||||
* payload back to the past. A snapshot that carries its own clock, or
|
||||
* moves the source, wins as is. Nested CASE, not AND: SQLite may reorder
|
||||
* AND terms, and the json_* readers raise on a malformed payload unless
|
||||
* json_valid ran first.
|
||||
*/
|
||||
export function playlistConflictUpdate(
|
||||
row: schema.NewPlaylist,
|
||||
playlist: Record<string, unknown>
|
||||
): PlaylistConflictUpdate {
|
||||
const replacement = withClearedColumns(row);
|
||||
if (readString(playlist['serverTimezone'])) {
|
||||
return row;
|
||||
return replacement;
|
||||
}
|
||||
const excludedPayload = sql.raw(`excluded.${stored.payload.name}`);
|
||||
const payload = sql`CASE
|
||||
@@ -48,7 +96,7 @@ export function playlistConflictUpdate(
|
||||
END
|
||||
ELSE ${excludedPayload}
|
||||
END`;
|
||||
return { ...row, payload };
|
||||
return { ...replacement, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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