Files
iptvnator/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts
T
4gray 83f6a270a5 fix(dashboard): give the hero the identity the detail view matched with (#1362)
The dashboard hero showed no backdrop for items whose detail page had one.
Two independent causes, both about identity rather than the matching gate.

Stalker items never reach the `content` table, so the xtream back-fill of
`content.backdrop_url` has no equivalent for them — but the enriched backdrop
is already sitting in the stored playlist entry (`info.tmdb_backdrop`). The
activity mappers now surface it as `backdrop_url`, where the hero already
looks first.

The hero's TMDB lookup ran on the display title alone, while the detail view
searched with the original title and the release year. Without a year
`pickConfidentMatch` requires a single exact title match, which common titles
never satisfy, and the miss lands in the negative cache under a lookup key the
detail view's hit can never be found at. The query is now built from the same
fields (`extractStalkerItemTmdbHints`), and the resolved `tmdb_id`
short-circuits the search entirely.

A 'movie' verdict retries as 'tv' without the id — 'movie' is the answer every
row falls back to, and an id is valid only for its own media type. A 'tv'
verdict, reached only on positive series evidence, gets no retry back to
'movie'.

Also removes `buildStalkerRecentItems`, a dead duplicate of the mapper the
dashboard actually uses.
2026-08-02 20:31:51 +02:00

135 lines
4.2 KiB
TypeScript

import { PlaylistMeta } from './playlist-meta.type';
import { PortalRecentItem } from './portal-activity-item.interface';
import { extractStalkerItemTmdbHints } from './stalker-item-tmdb-hints';
import {
extractStalkerItemId,
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemType,
normalizeStalkerDate,
} from './stalker-item.normalizer';
import {
PlaylistRecentlyViewedItem,
isM3uRecentlyViewedItem,
} from './playlist-recently-viewed.interface';
interface PlaylistRecentLabels {
stalker: string;
m3u: string;
}
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
return values.map((value) => value?.trim()).find(Boolean);
}
function getPlaylistRecentItems(
playlist: PlaylistMeta
): PlaylistRecentlyViewedItem[] {
return Array.isArray(playlist.recentlyViewed)
? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[])
: [];
}
function mapStalkerPlaylistRecentItems(
playlist: PlaylistMeta,
defaultPlaylistName: string
): PortalRecentItem[] {
return getPlaylistRecentItems(playlist).reduce<PortalRecentItem[]>(
(acc, rawItem, index) => {
if (isM3uRecentlyViewedItem(rawItem)) {
return acc;
}
const item = (rawItem ?? {}) as Record<string, unknown>;
const id = extractStalkerItemId(item, playlist._id, index);
acc.push({
id,
title: extractStalkerItemTitle(item),
type: extractStalkerItemType(item),
playlist_id: playlist._id,
playlist_name: playlist.title || defaultPlaylistName,
viewed_at: normalizeStalkerDate(item['added_at']),
category_id: String(item['category_id'] ?? ''),
xtream_id: id,
poster_url: extractStalkerItemPoster(item),
// Stalker has no `content` row to back-fill, so the backdrop
// travels inside the stored entry itself — present once the
// detail view has enriched the item.
backdrop_url: extractStalkerItemTmdbHints(item).backdropUrl,
source: 'stalker',
stalker_item: rawItem,
});
return acc;
},
[]
);
}
function mapM3uPlaylistRecentItems(
playlist: PlaylistMeta,
defaultPlaylistName: string
): PortalRecentItem[] {
return getPlaylistRecentItems(playlist).reduce<PortalRecentItem[]>(
(acc, rawItem) => {
if (!isM3uRecentlyViewedItem(rawItem)) {
return acc;
}
const channelUrl = rawItem.url.trim();
if (!channelUrl) {
return acc;
}
acc.push({
id: rawItem.id || channelUrl,
title:
firstNonEmpty(
rawItem.title,
rawItem.tvg_name,
rawItem.channel_id
) ||
channelUrl,
type: 'live',
playlist_id: playlist._id,
playlist_name: playlist.title || defaultPlaylistName,
viewed_at: normalizeStalkerDate(rawItem.added_at),
category_id: rawItem.category_id || 'live',
xtream_id: channelUrl,
poster_url: rawItem.poster_url || undefined,
epg_lookup_key: firstNonEmpty(
rawItem.tvg_id,
rawItem.tvg_name,
rawItem.title,
rawItem.channel_id
),
source: 'm3u',
});
return acc;
},
[]
);
}
export function buildPlaylistRecentItems(
playlists: PlaylistMeta[],
labels: PlaylistRecentLabels
): PortalRecentItem[] {
return playlists.reduce<PortalRecentItem[]>((acc, playlist) => {
if (playlist.macAddress) {
acc.push(
...mapStalkerPlaylistRecentItems(playlist, labels.stalker)
);
return acc;
}
if (!playlist.serverUrl) {
acc.push(...mapM3uPlaylistRecentItems(playlist, labels.m3u));
}
return acc;
}, []);
}