feat(dashboard): portal EPG on the live rails, loaded lazily per visible card

Xtream and Stalker live cards on the dashboard showed only the LIVE chip: they carry no XMLTV key and the rails never asked the portal. They now get their programme from the portal one card at a time, only once the card is on screen, with a bounded queue (2 in flight, 200 ms apart, 60 s TTL, 30 s failure cooldown) and every answer published as it lands, so the page never waits and a slow portal delays no other card. The rail reports visible cards through an IntersectionObserver; the presenter turns them plus the pinned hero into the wanted set; the service resolves each through StreamResolverService.loadEpgForItems, the same call the See all pages make. A shimmer placeholder shows only before a card's first answer. Follow-up to #1637.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-20 21:51:52 +02:00
1 parent 954c8ad65e
commit 9fc487dcb3
18 files changed
+1653 -31

No files matched your search

@@ -1,4 +1,6 @@
export * from './lib/dashboard-data.service';
export * from './lib/dashboard-portal-live-epg.service';
export * from './lib/dashboard-portal-live-epg.util';
export * from './lib/dashboard-recommendations.service';
export * from './lib/dashboard-recommendations.util';
export * from './lib/dashboard-source-expiry.service';
@@ -0,0 +1,243 @@
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import type { EpgProgram } from '@iptvnator/shared/interfaces';
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
import { StreamResolverService } from '@iptvnator/portal/shared/data-access';
import {
DASHBOARD_PORTAL_LIVE_EPG_TIMING,
DashboardPortalLiveEpgService,
} from './dashboard-portal-live-epg.service';
import type { DashboardPortalLiveEpgEntry } from './dashboard-portal-live-epg.util';
interface Deferred {
readonly key: string;
resolve: (program: EpgProgram | null) => void;
reject: (error: unknown) => void;
}
const entry = (id: number): DashboardPortalLiveEpgEntry => ({
key: `xtream::p::${id}`,
item: {
uid: `xtream::p::${id}`,
name: `Channel ${id}`,
contentType: 'live',
sourceType: 'xtream',
playlistId: 'p',
playlistName: 'Portal',
xtreamId: id,
tvgId: String(id),
},
});
const program = (title: string, stopIso?: string): EpgProgram =>
({
channel: 'x',
title,
start: '2026-05-23T10:00:00.000Z',
stop: stopIso ?? '2026-05-23T11:00:00.000Z',
}) as EpgProgram;
describe('DashboardPortalLiveEpgService', () => {
let service: DashboardPortalLiveEpgService;
let loadEpgForItems: jest.Mock;
let deferred: Deferred[];
let offsetMinutes: number;
let sourceChanged: Subject<void>;
const { delayMs, ttlMs, failureCooldownMs, endedRefetchFloorMs } =
DASHBOARD_PORTAL_LIVE_EPG_TIMING;
/** Let the queue loop take its next step (one inter-request delay). */
const step = async (rounds = 1): Promise<void> => {
for (let i = 0; i < rounds; i++) {
await jest.advanceTimersByTimeAsync(delayMs);
}
};
/** Settle the LATEST request for `key` — a key can be asked more than once. */
const settle = async (key: string, value: EpgProgram | null) => {
const request = [...deferred]
.reverse()
.find((request) => request.key === key);
if (!request) throw new Error(`no request in flight for ${key}`);
request.resolve(value);
await jest.advanceTimersByTimeAsync(0);
};
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z'));
deferred = [];
offsetMinutes = 0;
sourceChanged = new Subject<void>();
loadEpgForItems = jest.fn((items: { tvgId?: string }[]) => {
const key = `xtream::p::${items[0].tvgId}`;
return new Promise<Map<string, EpgProgram | null>>(
(resolve, reject) => {
deferred.push({
key,
resolve: (value) =>
resolve(new Map([[String(items[0].tvgId), value]])),
reject,
});
}
);
});
TestBed.configureTestingModule({
providers: [
DashboardPortalLiveEpgService,
{
provide: StreamResolverService,
useValue: { loadEpgForItems },
},
{
provide: SettingsStore,
useValue: {
resolvedEpgOffsetMinutes: () => offsetMinutes,
},
},
{
provide: EpgSourceSettingsService,
useValue: { changed$: sourceChanged },
},
],
});
service = TestBed.inject(DashboardPortalLiveEpgService);
});
afterEach(() => {
jest.useRealTimers();
});
it('answers each card as its own request lands, never waiting for the slowest', async () => {
service.sync([entry(1), entry(2)]);
expect(service.pending()).toEqual(
new Set(['xtream::p::1', 'xtream::p::2'])
);
await step(2);
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
await settle('xtream::p::2', program('Fast answer'));
expect(service.programs().get('xtream::p::2')?.title).toBe(
'Fast answer'
);
expect(service.programs().has('xtream::p::1')).toBe(false);
expect(service.pending()).toEqual(new Set(['xtream::p::1']));
await settle('xtream::p::1', null);
expect(service.programs().get('xtream::p::1')).toBeNull();
expect(service.pending().size).toBe(0);
});
it('never has more than two requests in flight and spaces starts by the delay', async () => {
service.sync([entry(1), entry(2), entry(3), entry(4)]);
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
await step(3);
// Two in flight: the loop keeps waiting instead of starting a third.
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
await settle('xtream::p::1', null);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(3);
});
it('drops a queued card that scrolled away before its turn and keeps the rest', async () => {
service.sync([entry(1), entry(2), entry(3)]);
// 1 started; 2 and 3 still queued.
service.sync([entry(1), entry(3)]);
expect(service.pending()).toEqual(
new Set(['xtream::p::1', 'xtream::p::3'])
);
await step(2);
const requested = loadEpgForItems.mock.calls.map(
([items]) => items[0].tvgId
);
expect(requested).toEqual(['1', '3']);
});
it('serves a fresh answer from cache and asks again once the TTL has passed', async () => {
service.sync([entry(1)]);
await settle('xtream::p::1', program('Cached'));
await step();
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + ttlMs);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
});
it('asks again when the programme on air has ended, but not more often than the floor', async () => {
service.sync([entry(1)]);
await settle(
'xtream::p::1',
program('Ending soon', '2026-05-23T10:35:00.000Z')
);
await step();
// Ended 5 min in, but the floor (30 s) already passed → refetch.
jest.setSystemTime(Date.now() + 6 * 60_000);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
// Portal keeps returning the ended row: not re-asked within the floor.
await settle(
'xtream::p::1',
program('Still ended', '2026-05-23T10:35:00.000Z')
);
await step();
jest.setSystemTime(Date.now() + endedRefetchFloorMs / 2);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
});
it('leaves a failed portal alone for the cooldown and keeps the card unanswered', async () => {
service.sync([entry(1)]);
deferred[0].reject(new Error('portal down'));
await jest.advanceTimersByTimeAsync(0);
expect(service.programs().has('xtream::p::1')).toBe(false);
expect(service.pending().size).toBe(0);
jest.setSystemTime(Date.now() + failureCooldownMs / 2);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + failureCooldownMs);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
});
it('retires an answer evaluated under a previous display offset and asks again', async () => {
service.sync([entry(1)]);
offsetMinutes = 60;
await settle('xtream::p::1', program('Old clock'));
await step();
expect(service.programs().has('xtream::p::1')).toBe(false);
// Still wanted → requeued under the new clock.
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
await settle('xtream::p::1', program('New clock'));
expect(service.programs().get('xtream::p::1')?.title).toBe('New clock');
});
it('drops every answer when the EPG sources change and asks the wanted cards again', async () => {
service.sync([entry(1)]);
await settle('xtream::p::1', program('Before import'));
await step();
sourceChanged.next();
expect(service.programs().size).toBe(0);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,263 @@
import { inject, Injectable, OnDestroy, signal } from '@angular/core';
import {
epgProviderClockMs,
type EpgProgram,
} from '@iptvnator/shared/interfaces';
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
import { StreamResolverService } from '@iptvnator/portal/shared/data-access';
import {
dashboardPortalLiveEpgProgramStopMs,
resolveDashboardPortalLiveEpgProgram,
type DashboardPortalLiveEpgEntry,
} from './dashboard-portal-live-epg.util';
interface CachedProgram {
readonly program: EpgProgram | null;
readonly fetchedAt: number;
}
/**
* Same numbers `EpgQueueService` uses against real Xtream panels: two
* requests in flight, 200 ms between starts. A card's answer is trusted for
* a minute; a portal that failed is left alone for 30 s; a programme that
* ended is asked again, but never more often than every 30 s in case the
* portal keeps returning the stale row.
*/
export const DASHBOARD_PORTAL_LIVE_EPG_TIMING = Object.freeze({
maxConcurrency: 2,
delayMs: 200,
ttlMs: 60_000,
failureCooldownMs: 30_000,
endedRefetchFloorMs: 30_000,
});
/**
* Lazy, per-card "what is on air" for the dashboard's Xtream and Stalker
* live cards. `sync()` receives the cards currently worth asking for — the
* visible ones — and the service answers each through the collection
* resolver one card at a time, publishing every answer the moment it lands
* (`programs`) so a slow portal never delays a fast one. The queue holds
* only wanted keys, so a card scrolled away before its turn is never
* requested. One instance for the app: favourites and recent rows of the
* same channel share the key and therefore the answer.
*/
@Injectable({ providedIn: 'root' })
export class DashboardPortalLiveEpgService implements OnDestroy {
private readonly streamResolver = inject(StreamResolverService);
private readonly settingsStore = inject(SettingsStore);
private readonly sourceSubscription = inject(
EpgSourceSettingsService
).changed$.subscribe(() => this.retireAnswers());
private readonly cache = new Map<string, CachedProgram>();
private readonly failureAt = new Map<string, number>();
private wanted = new Map<string, DashboardPortalLiveEpgEntry>();
private queue: string[] = [];
private readonly inFlight = new Set<string>();
private processing = false;
/** Display offset every cached answer was evaluated under. */
private stateOffsetMinutes = this.offsetMinutes();
private readonly programsState = signal<
ReadonlyMap<string, EpgProgram | null>
>(new Map());
private readonly pendingState = signal<ReadonlySet<string>>(new Set());
/** Answers by entry key; absent = not asked yet, `null` = nothing on air. */
readonly programs = this.programsState.asReadonly();
/** Keys queued or in flight — the cards that may show a placeholder. */
readonly pending = this.pendingState.asReadonly();
/**
* Replace the wanted set. Keys without a fresh answer are queued; keys no
* longer wanted are dropped from the queue (an in-flight request is
* allowed to finish and is cached for when the card scrolls back).
*/
sync(entries: readonly DashboardPortalLiveEpgEntry[]): void {
this.retireStateOfPreviousOffset();
this.wanted = new Map(entries.map((entry) => [entry.key, entry]));
this.queue = this.queue.filter((key) => this.wanted.has(key));
const now = Date.now();
for (const entry of entries) {
if (
this.needsFetch(entry.key, now) &&
!this.queue.includes(entry.key)
) {
this.queue.push(entry.key);
}
}
this.publishPending();
if (!this.processing && this.queue.length > 0) {
void this.processQueue();
}
}
ngOnDestroy(): void {
this.sourceSubscription.unsubscribe();
}
private offsetMinutes(): number {
return this.settingsStore.resolvedEpgOffsetMinutes();
}
private needsFetch(key: string, now: number): boolean {
if (this.inFlight.has(key) || this.isCoolingDown(key, now)) {
return false;
}
const cached = this.cache.get(key);
if (!cached) {
return true;
}
if (now - cached.fetchedAt >= DASHBOARD_PORTAL_LIVE_EPG_TIMING.ttlMs) {
return true;
}
const stopMs = dashboardPortalLiveEpgProgramStopMs(cached.program);
return (
stopMs !== null &&
epgProviderClockMs(now, this.stateOffsetMinutes) >= stopMs &&
now - cached.fetchedAt >=
DASHBOARD_PORTAL_LIVE_EPG_TIMING.endedRefetchFloorMs
);
}
private isCoolingDown(key: string, now: number): boolean {
const failedAt = this.failureAt.get(key);
if (failedAt == null) {
return false;
}
if (
now - failedAt >=
DASHBOARD_PORTAL_LIVE_EPG_TIMING.failureCooldownMs
) {
this.failureAt.delete(key);
return false;
}
return true;
}
private async processQueue(): Promise<void> {
this.processing = true;
try {
while (this.queue.length > 0) {
if (
this.inFlight.size >=
DASHBOARD_PORTAL_LIVE_EPG_TIMING.maxConcurrency
) {
await this.delay();
continue;
}
const key = this.queue.shift();
if (
key == null ||
!this.wanted.has(key) ||
!this.needsFetch(key, Date.now())
) {
this.publishPending();
continue;
}
this.inFlight.add(key);
void this.fetch(key);
await this.delay();
}
} finally {
this.processing = false;
}
}
private async fetch(key: string): Promise<void> {
const entry = this.wanted.get(key);
if (!entry) {
this.inFlight.delete(key);
this.publishPending();
return;
}
const offsetMinutes = this.offsetMinutes();
let program: EpgProgram | null = null;
let failed = false;
try {
const epgMap = await this.streamResolver.loadEpgForItems([
entry.item,
]);
program = resolveDashboardPortalLiveEpgProgram(epgMap, entry);
} catch {
failed = true;
}
this.inFlight.delete(key);
// The setting changed while the request was on the wire: this answer
// belongs to the previous provider clock. Retire it and ask again if
// the card is still wanted.
if (offsetMinutes !== this.offsetMinutes()) {
this.retireStateOfPreviousOffset();
this.requeueIfWanted(key);
return;
}
if (failed) {
this.failureAt.set(key, Date.now());
} else {
this.cache.set(key, { program, fetchedAt: Date.now() });
this.programsState.update((programs) => {
const next = new Map(programs);
next.set(key, program);
return next;
});
}
this.publishPending();
}
private requeueIfWanted(key: string): void {
if (this.wanted.has(key) && !this.queue.includes(key)) {
this.queue.push(key);
}
this.publishPending();
if (!this.processing && this.queue.length > 0) {
void this.processQueue();
}
}
/**
* Every answer here is "what is on at the provider clock", so a changed
* display offset drops all of them. The caller's own `sync` (the
* presenter re-syncs on an offset change) asks the wanted cards again.
*/
private retireStateOfPreviousOffset(): void {
const current = this.offsetMinutes();
if (current === this.stateOffsetMinutes) {
return;
}
this.stateOffsetMinutes = current;
this.dropAnswers();
}
/** Removed or re-imported XMLTV: drop every answer and ask again now. */
private retireAnswers(): void {
this.dropAnswers();
if (this.wanted.size > 0) {
this.sync(Array.from(this.wanted.values()));
}
}
private dropAnswers(): void {
this.cache.clear();
this.failureAt.clear();
this.programsState.set(new Map());
}
private publishPending(): void {
const pending = new Set<string>();
for (const key of this.queue) {
if (this.wanted.has(key)) pending.add(key);
}
for (const key of this.inFlight) {
if (this.wanted.has(key)) pending.add(key);
}
this.pendingState.set(pending);
}
private delay(): Promise<void> {
return new Promise((resolve) =>
setTimeout(resolve, DASHBOARD_PORTAL_LIVE_EPG_TIMING.delayMs)
);
}
}
@@ -0,0 +1,160 @@
import type {
EpgProgram,
PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import {
buildDashboardPortalLiveEpgEntry,
buildDashboardPortalLiveEpgKey,
dashboardPortalLiveEpgProgramStopMs,
resolveDashboardPortalLiveEpgProgram,
} from './dashboard-portal-live-epg.util';
const baseItem = (overrides: Partial<PortalActivityItem>): PortalActivityItem =>
({
id: 'item-1',
title: 'News 24',
type: 'live',
playlist_id: 'playlist-1',
playlist_name: 'My portal',
category_id: '7',
xtream_id: 42,
poster_url: 'https://example.com/logo.png',
...overrides,
}) as PortalActivityItem;
describe('buildDashboardPortalLiveEpgEntry', () => {
it('builds an Xtream live entry keyed by the collection uid with the stream id as tvgId', () => {
const entry = buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'xtream', xtream_id: 42 })
);
expect(entry?.key).toBe('xtream::playlist-1::42');
expect(entry?.item).toMatchObject({
uid: 'xtream::playlist-1::42',
name: 'News 24',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'playlist-1',
playlistName: 'My portal',
logo: 'https://example.com/logo.png',
xtreamId: 42,
tvgId: '42',
categoryId: '7',
});
});
it('accepts a numeric string Xtream id and rejects ids that cannot name a stream', () => {
expect(
buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'xtream', xtream_id: '17' })
)?.item.xtreamId
).toBe(17);
for (const xtream_id of ['', 'abc', 0, -3, 1.5]) {
expect(
buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'xtream', xtream_id })
)
).toBeNull();
}
});
it('builds a Stalker live entry from the stored portal item, keyed by the extracted id', () => {
const stalkerItem = {
id: 'ch-9',
cmd: 'ffrt http://portal/9',
radio: false,
};
const entry = buildDashboardPortalLiveEpgEntry(
baseItem({
source: 'stalker',
id: 'ch-9',
xtream_id: 'ch-9',
stalker_item: stalkerItem,
})
);
expect(entry?.key).toBe('stalker::playlist-1::ch-9');
expect(entry?.item).toMatchObject({
sourceType: 'stalker',
stalkerId: 'ch-9',
tvgId: 'ch-9',
stalkerCmd: 'ffrt http://portal/9',
stalkerItem,
});
});
it('skips Stalker radio rows and rows without the stored item, like the collection resolver', () => {
expect(
buildDashboardPortalLiveEpgEntry(
baseItem({
source: 'stalker',
stalker_item: { id: 'r-1', radio: 'true' },
})
)
).toBeNull();
expect(
buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'stalker', stalker_item: undefined })
)
).toBeNull();
});
it('leaves M3U rows and non-live rows to the XMLTV batch', () => {
expect(
buildDashboardPortalLiveEpgEntry(baseItem({ source: 'm3u' }))
).toBeNull();
expect(
buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'xtream', type: 'movie' })
)
).toBeNull();
expect(
buildDashboardPortalLiveEpgKey(baseItem({ source: 'xtream' }))
).toBe('xtream::playlist-1::42');
expect(
buildDashboardPortalLiveEpgKey(baseItem({ source: 'm3u' }))
).toBeNull();
});
});
describe('resolveDashboardPortalLiveEpgProgram', () => {
it('reads the resolver map by the entry tvgId and treats a missing key as nothing on air', () => {
const entry = buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'xtream', xtream_id: 42 })
);
const program = { title: 'Evening news' } as EpgProgram;
if (!entry) throw new Error('expected an entry');
expect(
resolveDashboardPortalLiveEpgProgram(
new Map([['42', program]]),
entry
)
).toBe(program);
expect(
resolveDashboardPortalLiveEpgProgram(new Map(), entry)
).toBeNull();
});
});
describe('dashboardPortalLiveEpgProgramStopMs', () => {
it('prefers the unix-seconds stop, falls back to the ISO string, and reports null otherwise', () => {
expect(
dashboardPortalLiveEpgProgramStopMs({
stopTimestamp: 1_700_000_000,
stop: '2026-01-01T00:00:00.000Z',
} as EpgProgram)
).toBe(1_700_000_000_000);
expect(
dashboardPortalLiveEpgProgramStopMs({
stop: '2026-01-01T00:00:00.000Z',
} as EpgProgram)
).toBe(Date.parse('2026-01-01T00:00:00.000Z'));
expect(
dashboardPortalLiveEpgProgramStopMs({
stop: 'garbage',
} as EpgProgram)
).toBeNull();
expect(dashboardPortalLiveEpgProgramStopMs(null)).toBeNull();
});
});
@@ -0,0 +1,136 @@
import {
isStalkerRadioItem,
type EpgProgram,
type PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import {
buildCollectionUid,
type UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
/**
* One dashboard live card's portal EPG request: the key its answer is filed
* under (the collection uid, stable across favourites and recent rows of the
* same channel) and the item `StreamResolverService.loadEpgForItems` fetches
* for — the same shape the "See all" pages hand it, so the two surfaces
* resolve a channel identically.
*/
export interface DashboardPortalLiveEpgEntry {
readonly key: string;
readonly item: UnifiedCollectionItem;
}
/** The key alone, for cards that only need to find their answer. */
export function buildDashboardPortalLiveEpgKey(
item: PortalActivityItem
): string | null {
return buildDashboardPortalLiveEpgEntry(item)?.key ?? null;
}
/**
* M3U channels keep the dashboard's batched XMLTV lookup; Xtream and Stalker
* live rows have no XMLTV key of their own and are answered by their portal.
* Radio rows are skipped like the collection resolver skips them.
*/
export function buildDashboardPortalLiveEpgEntry(
item: PortalActivityItem
): DashboardPortalLiveEpgEntry | null {
if (item.type !== 'live') {
return null;
}
if (item.source === 'xtream') {
return buildXtreamEntry(item);
}
if (item.source === 'stalker') {
return buildStalkerEntry(item);
}
return null;
}
/**
* The resolver keys its map by the item's `tvgId`; the entry sets it to the
* provider id, so a missing key means "asked, nothing on air".
*/
export function resolveDashboardPortalLiveEpgProgram(
epgMap: ReadonlyMap<string, EpgProgram | null>,
entry: DashboardPortalLiveEpgEntry
): EpgProgram | null {
const key = entry.item.tvgId?.trim();
return key ? (epgMap.get(key) ?? null) : null;
}
/**
* When the programme on air ends, in wall-clock milliseconds; `null` when the
* row states no usable end. Reads the pre-computed unix-seconds field first,
* then the ISO string, like the dashboard's own time-range helper.
*/
export function dashboardPortalLiveEpgProgramStopMs(
program: EpgProgram | null
): number | null {
if (!program) {
return null;
}
const cached = Number(program.stopTimestamp);
if (Number.isFinite(cached) && cached > 0) {
return cached * 1000;
}
const parsed = program.stop ? Date.parse(program.stop) : NaN;
return Number.isFinite(parsed) ? parsed : null;
}
function buildXtreamEntry(
item: PortalActivityItem
): DashboardPortalLiveEpgEntry | null {
const xtreamId = Number(item.xtream_id);
if (!Number.isInteger(xtreamId) || xtreamId <= 0) {
return null;
}
const key = buildCollectionUid('xtream', item.playlist_id, xtreamId);
return {
key,
item: {
uid: key,
name: item.title,
contentType: 'live',
sourceType: 'xtream',
playlistId: item.playlist_id,
playlistName: item.playlist_name ?? 'Xtream',
logo: item.poster_url ?? null,
xtreamId,
tvgId: String(xtreamId),
categoryId: item.category_id,
},
};
}
function buildStalkerEntry(
item: PortalActivityItem
): DashboardPortalLiveEpgEntry | null {
const raw = item.stalker_item;
if (!raw || isStalkerRadioItem(raw)) {
return null;
}
// Both dashboard mappers already store the extracted portal id as `id`.
const stalkerId = String(item.id ?? '').trim();
if (!stalkerId) {
return null;
}
const key = buildCollectionUid('stalker', item.playlist_id, stalkerId);
return {
key,
item: {
uid: key,
name: item.title,
contentType: 'live',
sourceType: 'stalker',
playlistId: item.playlist_id,
playlistName: item.playlist_name ?? 'Stalker',
logo: item.poster_url ?? null,
stalkerId,
tvgId: stalkerId,
stalkerCmd: raw.cmd,
categoryId: item.category_id,
stalkerItem: raw,
},
};
}