mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
An Xtream activity row is built from its `content` row, and the catalog endpoints that create those rows carry only a title and a poster. So the dashboard hero and the recommendations rail rebuilt their TMDB query from the display title alone, while the detail view had searched with the original title, the release date and often a TMDB id. Without a year `pickConfidentMatch` requires a globally unique exact title, which common titles never satisfy — "Inside Out" matches several films and resolves to nothing, every time. Three `content` columns close that gap next to the existing `backdrop_url`: `tmdb_id`, `release_year`, `original_title`. The detail views back-fill them from what is on screen, the activity SELECTs project them, and `buildDashboardTmdbAttempts` reads them back. Stalker keeps stating the same facts through its stored entry, and rows with neither keep the title-only fallback. Measured against a real profile before building: of 58 distinct Xtream movie/series activity rows, 16 (28%) produce a year-less key — the cohort where a miss is guaranteed rather than likely. Contracts worth preserving: - Per-column, never overwrite. Enrichment supplies the pieces at different times, so a row-level guard would let the first arrival block every later one forever. - `release_year` is the year the PROVIDER stated. The TMDB merge fills the date field when the provider left it empty, so it marks its own substitution with `tmdb_supplied_release_date` and the extractor skips those — making contamination structurally impossible rather than avoided. - The id is stored unvetted: every consumer re-gates it through `assessProviderId`, which re-decides per lookup where a write-time verdict would be permanent. - No media-type column — for Xtream the catalog files movies and series apart, so `content.type` already is the media type. Worker requests now await `getDatabase()` before dispatching. The renderer loads before `initDatabase()` and the worker opens the database file without running migrations, so a query issued during startup on an upgraded install could otherwise hit a schema whose new columns do not exist yet. Not covered: the PWA, whose catalog cache is rebuilt from the API on every load, so a stored id would never outlive the detail view that resolved it.
189 lines
6.3 KiB
TypeScript
189 lines
6.3 KiB
TypeScript
import {
|
|
GlobalFavoriteItem as DbGlobalFavoriteItem,
|
|
GlobalRecentlyAddedItem as DbGlobalRecentlyAddedItem,
|
|
GlobalRecentItem as DbGlobalRecentItem,
|
|
} from '@iptvnator/services';
|
|
import {
|
|
PortalAddedItem,
|
|
PlaylistMeta,
|
|
PortalActivityItem,
|
|
PortalActivityType,
|
|
PortalFavoriteItem,
|
|
PortalRecentItem,
|
|
normalizeContentMetadataPatch,
|
|
extractStalkerItemId,
|
|
extractStalkerItemPoster,
|
|
extractStalkerItemTitle,
|
|
extractStalkerItemTmdbHints,
|
|
extractStalkerItemType,
|
|
normalizeStalkerDate,
|
|
} from '@iptvnator/shared/interfaces';
|
|
|
|
// ────── Type / label helpers ──────
|
|
|
|
export function normalizeActivityType(value: string): PortalActivityType {
|
|
return value === 'live' || value === 'series' ? value : 'movie';
|
|
}
|
|
|
|
export function getActivityTypeLabelKey(type: PortalActivityType): string {
|
|
if (type === 'live') return 'WORKSPACE.DASHBOARD.TYPE_LIVE';
|
|
if (type === 'series') return 'WORKSPACE.DASHBOARD.TYPE_SERIES';
|
|
return 'WORKSPACE.DASHBOARD.TYPE_MOVIE';
|
|
}
|
|
|
|
// ────── Xtream DB → ViewModel ──────
|
|
|
|
/**
|
|
* The TMDB identity a detail view left on the `content` row, if any.
|
|
*
|
|
* Read through the same normalizer the write path uses, so a legacy row, a
|
|
* row whose detail page has never been opened, and a row carrying a value the
|
|
* provider fabricated all collapse to the same thing: absent fields, and a
|
|
* caller that falls back to the display title.
|
|
*/
|
|
function readContentTmdbIdentity(item: {
|
|
tmdb_id?: number | null;
|
|
release_year?: number | null;
|
|
original_title?: string | null;
|
|
}): Pick<PortalActivityItem, 'tmdb_id' | 'release_year' | 'original_title'> {
|
|
const identity = normalizeContentMetadataPatch({
|
|
tmdbId: item.tmdb_id ?? undefined,
|
|
releaseYear: item.release_year ?? undefined,
|
|
originalTitle: item.original_title ?? undefined,
|
|
});
|
|
|
|
return identity
|
|
? {
|
|
tmdb_id: identity.tmdbId,
|
|
release_year: identity.releaseYear,
|
|
original_title: identity.originalTitle,
|
|
}
|
|
: {};
|
|
}
|
|
|
|
export function mapDbFavoriteToItem(
|
|
item: DbGlobalFavoriteItem
|
|
): PortalFavoriteItem {
|
|
return {
|
|
id: item.id,
|
|
title: item.title,
|
|
type: normalizeActivityType(item.type),
|
|
playlist_id: item.playlist_id,
|
|
playlist_name: item.playlist_name,
|
|
added_at: normalizeStalkerDate(item.added_at),
|
|
category_id: item.category_id,
|
|
xtream_id: item.xtream_id,
|
|
poster_url: item.poster_url,
|
|
backdrop_url: item.backdrop_url ?? undefined,
|
|
...readContentTmdbIdentity(item),
|
|
source: 'xtream',
|
|
};
|
|
}
|
|
|
|
export function mapDbRecentToItem(item: DbGlobalRecentItem): PortalRecentItem {
|
|
return {
|
|
id: item.id,
|
|
title: item.title,
|
|
type: normalizeActivityType(item.type),
|
|
playlist_id: item.playlist_id,
|
|
playlist_name: item.playlist_name,
|
|
viewed_at: normalizeStalkerDate(item.viewed_at),
|
|
category_id: item.category_id,
|
|
xtream_id: item.xtream_id,
|
|
poster_url: item.poster_url,
|
|
backdrop_url: item.backdrop_url ?? undefined,
|
|
...readContentTmdbIdentity(item),
|
|
source: 'xtream',
|
|
};
|
|
}
|
|
|
|
export function mapDbRecentlyAddedToItem(
|
|
item: DbGlobalRecentlyAddedItem
|
|
): PortalAddedItem {
|
|
return {
|
|
id: item.id,
|
|
title: item.title,
|
|
type: normalizeActivityType(item.type),
|
|
playlist_id: item.playlist_id,
|
|
playlist_name: item.playlist_name,
|
|
added_at: normalizeStalkerDate(item.added_at),
|
|
category_id: item.category_id,
|
|
xtream_id: item.xtream_id,
|
|
poster_url: item.poster_url,
|
|
source: 'xtream',
|
|
};
|
|
}
|
|
|
|
// ────── Stalker playlist → ViewModel ──────
|
|
|
|
export function buildStalkerFavoriteItems(
|
|
playlists: PlaylistMeta[],
|
|
defaultPlaylistName: string
|
|
): PortalFavoriteItem[] {
|
|
return playlists
|
|
.filter((playlist) => Boolean(playlist.macAddress))
|
|
.reduce<PortalFavoriteItem[]>((acc, playlist) => {
|
|
const favorites = Array.isArray(playlist.favorites)
|
|
? playlist.favorites
|
|
: [];
|
|
|
|
const mapped = favorites.map((item, index) => {
|
|
const raw = (item ?? {}) as Record<string, unknown>;
|
|
const id = extractStalkerItemId(raw, playlist._id, index);
|
|
|
|
return {
|
|
id,
|
|
title: extractStalkerItemTitle(raw),
|
|
type: extractStalkerItemType(raw),
|
|
playlist_id: playlist._id,
|
|
playlist_name: playlist.title || defaultPlaylistName,
|
|
added_at: normalizeStalkerDate(raw['added_at']),
|
|
category_id: String(raw['category_id'] ?? ''),
|
|
xtream_id: id,
|
|
poster_url: extractStalkerItemPoster(raw),
|
|
// Same source as the recent-items mapper: Stalker keeps
|
|
// the enriched backdrop inside the stored entry.
|
|
backdrop_url: extractStalkerItemTmdbHints(raw).backdropUrl,
|
|
source: 'stalker' as const,
|
|
stalker_item: item,
|
|
} as PortalFavoriteItem;
|
|
});
|
|
|
|
acc.push(...mapped);
|
|
return acc;
|
|
}, []);
|
|
}
|
|
|
|
// ────── Timestamp helpers ──────
|
|
|
|
export function toDateTimestamp(value: unknown): number {
|
|
if (typeof value === 'number') {
|
|
if (!Number.isFinite(value) || value <= 0) return 0;
|
|
return value > 10_000_000_000 ? value : value * 1000;
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
if (/^\d+$/.test(trimmed)) {
|
|
const numeric = Number(trimmed);
|
|
if (Number.isFinite(numeric) && numeric > 0) {
|
|
return numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
|
}
|
|
return 0;
|
|
}
|
|
const parsed = Date.parse(normalizeStalkerDate(trimmed) || trimmed);
|
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
export function toTimestamp(value?: string | number): number {
|
|
if (typeof value === 'number') return value;
|
|
if (typeof value === 'string') {
|
|
const parsed = Date.parse(normalizeStalkerDate(value) || value);
|
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
}
|
|
return 0;
|
|
}
|