feat(stalker): refactor epg handling to use get_epg_info

- Implemented unit tests for StalkerCollectionChannelsListComponent to verify EPG program loading and state management.
- Created unit tests for StalkerLiveStreamLayoutComponent to ensure proper rendering of EPG lists and handling of channel playback.
- Refactored EPG loading logic in StalkerLiveStreamLayoutComponent to improve performance and maintainability.
- Updated HTML template in StalkerLiveStreamLayoutComponent to replace deprecated components with new EPG list component.
- Enhanced EPG program handling by introducing methods for timestamp parsing and current program detection.
This commit is contained in:
4gray committed 2026-04-11 10:43:50 +02:00
1 parent bf427f7226
commit 545dfd8c67
14 files changed
+1542 -255

No files matched your search

+5
View File
@@ -4,6 +4,11 @@
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/remote-control-web/src",
"cli": {
"cache": {
"enabled": false
}
},
"tags": [],
"targets": {
"build": {
@@ -135,15 +135,6 @@ function logoUrl(seed: string): string {
return `https://picsum.photos/seed/logo-${seed}/100/100`;
}
function isoNow(offsetMinutes: number): string {
const d = new Date(Date.now() + offsetMinutes * 60 * 1000);
return d.toISOString();
}
function unixNow(offsetMinutes: number): number {
return Math.floor((Date.now() + offsetMinutes * 60 * 1000) / 1000);
}
// ---------------------------------------------------------------------------
// Generator
// ---------------------------------------------------------------------------
@@ -390,29 +381,44 @@ export function generateSeasons(
// EPG generator
// ---------------------------------------------------------------------------
const EPG_PROGRAM_TYPES = ['News', 'Movie', 'Documentary', 'Entertainment', 'Sports', 'Kids', 'Series'];
const EPG_PROGRAM_TYPES = [
'News',
'Movie',
'Documentary',
'Entertainment',
'Sports',
'Kids',
'Series',
];
export function generateEpg(channelName: string): RawEpgProgram[] {
const programs: RawEpgProgram[] = [];
// Generate 12 programs: 6 past, current, 5 future (30-min slots)
const SLOT_MINUTES = 30;
const startOffset = -6 * SLOT_MINUTES; // start 3 hours ago
const SLOT_MINUTES = 120;
const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
const TOTAL_DAYS = 7;
const dayStart = new Date();
dayStart.setUTCHours(0, 0, 0, 0);
for (let i = 0; i < 12; i++) {
const startMin = startOffset + i * SLOT_MINUTES;
const stopMin = startMin + SLOT_MINUTES;
for (let i = 0; i < SLOTS_PER_DAY * TOTAL_DAYS; i++) {
const startDate = new Date(
dayStart.getTime() + i * SLOT_MINUTES * 60 * 1000
);
const stopDate = new Date(
startDate.getTime() + SLOT_MINUTES * 60 * 1000
);
const category = EPG_PROGRAM_TYPES[i % EPG_PROGRAM_TYPES.length];
programs.push({
id: String(i + 1),
name: `${channelName}: ${faker.company.catchPhrase()}`,
start: isoNow(startMin),
stop: isoNow(stopMin),
start_timestamp: unixNow(startMin),
stop_timestamp: unixNow(stopMin),
start: startDate.toISOString(),
stop: stopDate.toISOString(),
start_timestamp: Math.floor(startDate.getTime() / 1000),
stop_timestamp: Math.floor(stopDate.getTime() / 1000),
descr: faker.lorem.sentence(),
category,
});
}
return programs;
}
@@ -0,0 +1,37 @@
import { Request, Response } from 'express';
import { getPortalData } from '../data-store.js';
import { extractMac } from './get-categories.handler.js';
/**
* Stalker get_epg_info — returns bulk EPG keyed by channel id.
*
* Query params:
* period: number of future hours to include (default 168)
*/
export function handleGetEpgInfo(req: Request, res: Response): void {
const mac = extractMac(req);
const period = parseInt((req.query['period'] as string) ?? '168', 10);
const data = getPortalData(mac);
const now = Math.floor(Date.now() / 1000);
const currentDayStart = new Date();
currentDayStart.setUTCHours(0, 0, 0, 0);
const startTimestamp = Math.floor(currentDayStart.getTime() / 1000);
const endTimestamp = now + Math.max(period, 1) * 60 * 60;
const epgByChannel = Object.fromEntries(
[...data.epg.entries()].map(([channelId, programs]) => [
channelId,
programs.filter(
(program) =>
program.stop_timestamp >= startTimestamp &&
program.start_timestamp <= endTimestamp
),
])
);
res.json({
js: {
data: epgByChannel,
},
});
}
@@ -24,9 +24,15 @@ export function handleGetShortEpg(req: Request, res: Response): void {
data.epg.set(channelId, programs);
}
const now = Math.floor(Date.now() / 1000);
const currentIndex = programs.findIndex(
(program) => program.stop_timestamp > now
);
const startIndex = currentIndex >= 0 ? currentIndex : 0;
res.json({
js: {
data: programs.slice(0, size),
data: programs.slice(startIndex, startIndex + size),
},
});
}
@@ -6,6 +6,7 @@ import { handleGetOrderedList } from '../handlers/get-ordered-list.handler.js';
import { handleGetSeasons } from '../handlers/get-seasons.handler.js';
import { handleCreateLink } from '../handlers/create-link.handler.js';
import { handleFavorites } from '../handlers/favorites.handler.js';
import { handleGetEpgInfo } from '../handlers/get-epg-info.handler.js';
import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js';
import { handleGetGenres } from '../handlers/get-genres.handler.js';
@@ -45,9 +46,11 @@ export default function dispatchPortalAction(req: Request, res: Response): void
handleFavorites(req, res);
break;
case 'get_short_epg':
case 'get_epg_info':
handleGetShortEpg(req, res);
break;
case 'get_epg_info':
handleGetEpgInfo(req, res);
break;
default:
console.warn(`[portal] Unknown action: ${action}`);
res.json({ js: { error: `Unknown action: ${action}` } });
+58 -4
View File
@@ -173,6 +173,13 @@ test('@stalker minimal scenario — correct item counts', async ({ page }) => {
test('@stalker EPG data loads for ITV channel', async ({ page }) => {
await addStalkerPortal(page);
const shortEpgRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('action=get_short_epg')) {
shortEpgRequests.push(request.url());
}
});
// Navigate to ITV tab
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
@@ -184,14 +191,61 @@ test('@stalker EPG data loads for ITV channel', async ({ page }) => {
// Wait for channels to appear
const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
expect(shortEpgRequests).toHaveLength(0);
// Click a channel — EPG info should appear
await channels.first().click();
await expect(channels.first()).toHaveClass(/active/, { timeout: 10_000 });
await expect(page.locator('main app-epg-view')).toBeVisible({
timeout: 10_000,
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
await expect(page.locator('app-epg-list')).toBeVisible({
timeout: 20_000,
});
await expect(page.locator('app-epg-list .selected-date')).toBeVisible({
timeout: 20_000,
});
});
test('@stalker bulk EPG is fetched once and reused across channel switches', async ({
page,
}) => {
await addStalkerPortal(page);
const epgInfoRequests: string[] = [];
const shortEpgRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('action=get_epg_info')) {
epgInfoRequests.push(request.url());
}
if (request.url().includes('action=get_short_epg')) {
shortEpgRequests.push(request.url());
}
});
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
const categories = page.locator('.category-item');
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.nth(1)).toBeVisible({ timeout: 20_000 });
expect(shortEpgRequests).toHaveLength(0);
await channels.first().click();
await expect(page.locator('app-epg-list')).toBeVisible({
timeout: 20_000,
});
await expect
.poll(() => epgInfoRequests.length, { timeout: 20_000 })
.toBe(1);
await channels.nth(1).click();
await expect(channels.nth(1)).toHaveClass(/active/, { timeout: 20_000 });
await expect
.poll(() => epgInfoRequests.length, { timeout: 20_000 })
.toBe(1);
expect(shortEpgRequests).toHaveLength(0);
});
test('@stalker mock server reset clears cached state', async ({ request }) => {
+5 -1
View File
@@ -4,6 +4,11 @@
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/web/src",
"cli": {
"cache": {
"enabled": false
}
},
"tags": ["scope:app", "type:app"],
"targets": {
"build": {
@@ -30,7 +35,6 @@
"allowedCommonJsDependencies": [
"qrcode",
"iptv-playlist-parser",
"moment",
"mpegts.js",
"videojs-contrib-quality-levels",
"videojs-quality-selector-hls",
@@ -0,0 +1,199 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { DataService } from 'services';
import { EpgItem, Playlist } from 'shared-interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerEpg } from './with-stalker-epg.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
const PLAYLIST = {
_id: 'playlist-1',
title: 'Demo Stalker',
count: 0,
autoRefresh: false,
importDate: '2026-04-11T00:00:00.000Z',
lastUsage: '2026-04-11T00:00:00.000Z',
portalUrl: 'http://demo.example/portal.php',
macAddress: '00:1A:79:00:00:01',
} as Playlist;
const TestStalkerEpgStore = signalStore(
withState({
currentPlaylist: PLAYLIST,
selectedItvId: '10001',
}),
withStalkerEpg()
);
describe('withStalkerEpg', () => {
let store: InstanceType<typeof TestStalkerEpgStore>;
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let stalkerSessionService: {
makeAuthenticatedRequest: jest.Mock<Promise<unknown>, unknown[]>;
};
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
stalkerSessionService = {
makeAuthenticatedRequest: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
TestStalkerEpgStore,
{ provide: DataService, useValue: dataService },
{
provide: StalkerSessionService,
useValue: stalkerSessionService,
},
],
});
store = TestBed.inject(TestStalkerEpgStore);
});
it('fetches fallback short EPG via get_short_epg', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: [
buildEntry('10001', 'Current Show', 1744365600, 1744367400),
],
},
});
const result = await store.fetchChannelEpg('10001');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
url: PLAYLIST.portalUrl,
macAddress: PLAYLIST.macAddress,
params: expect.objectContaining({
action: 'get_short_epg',
type: 'itv',
ch_id: '10001',
size: '10',
}),
})
);
expect(result).toEqual([
buildEpgItem('10001', 'Current Show', 1744365600, 1744367400),
]);
});
it('loads bulk EPG once and projects selected-channel programs from the cache', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: {
'10001': [
buildEntry('10001', 'Morning Show', 1744358400, 1744362000),
buildEntry('10001', 'Current Show', 1744362000, 1744365600),
],
'10002': [
buildEntry('10002', 'Other Channel', 1744362000, 1744365600),
],
},
},
});
await store.ensureBulkItvEpg(168);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: 'get_epg_info',
type: 'itv',
period: '168',
}),
})
);
expect(store.bulkItvEpgLoaded()).toBe(true);
expect(store.bulkItvEpgPlaylistId()).toBe('playlist-1');
expect(store.bulkItvEpgPeriodHours()).toBe(168);
expect(store.selectedItvEpgPrograms()).toEqual([
buildProgram('10001', 'Morning Show', 1744358400, 1744362000),
buildProgram('10001', 'Current Show', 1744362000, 1744365600),
]);
});
it('treats bulk EPG failures as loaded-empty cache so callers can fallback to short EPG', async () => {
dataService.sendIpcEvent.mockRejectedValue(new Error('unsupported'));
await store.ensureBulkItvEpg(168);
expect(store.bulkItvEpgLoaded()).toBe(true);
expect(store.bulkItvEpgByChannel()).toEqual({});
expect(store.selectedItvEpgPrograms()).toEqual([]);
expect(store.isLoadingBulkItvEpg()).toBe(false);
});
});
function buildEntry(
channelId: string,
title: string,
startTimestamp: number,
stopTimestamp: number
) {
return {
id: `${channelId}-${startTimestamp}`,
ch_id: channelId,
name: title,
descr: `${title} description`,
start: new Date(startTimestamp * 1000).toISOString(),
stop: new Date(stopTimestamp * 1000).toISOString(),
start_timestamp: startTimestamp,
stop_timestamp: stopTimestamp,
};
}
function buildEpgItem(
channelId: string,
title: string,
startTimestamp: number,
stopTimestamp: number
): EpgItem {
return {
id: `${channelId}-${startTimestamp}`,
epg_id: '',
title,
description: `${title} description`,
lang: '',
start: new Date(startTimestamp * 1000).toISOString(),
end: new Date(stopTimestamp * 1000).toISOString(),
stop: new Date(stopTimestamp * 1000).toISOString(),
channel_id: channelId,
start_timestamp: String(startTimestamp),
stop_timestamp: String(stopTimestamp),
};
}
function buildProgram(
channelId: string,
title: string,
startTimestamp: number,
stopTimestamp: number
) {
return {
start: new Date(startTimestamp * 1000).toISOString(),
stop: new Date(stopTimestamp * 1000).toISOString(),
channel: channelId,
title,
desc: `${title} description`,
category: null,
startTimestamp,
stopTimestamp,
};
}
@@ -1,38 +1,105 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withState,
} from '@ngrx/signals';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from 'services';
import {
EpgItem,
EpgProgram,
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
import { StalkerSessionService } from '../../stalker-session.service';
interface EpgStoreContext {
currentPlaylist(): Playlist | undefined;
selectedItvId(): string | undefined;
}
interface StalkerEpgEntry {
id?: string | number;
name?: string;
descr?: string;
category?: string;
time?: string;
time_to?: string;
start?: string;
stop?: string;
ch_id?: string | number;
start_timestamp?: string | number;
stop_timestamp?: string | number;
}
type StalkerBulkEpgValue =
| StalkerEpgEntry[]
| {
data?: StalkerEpgEntry[];
epg?: StalkerEpgEntry[];
items?: StalkerEpgEntry[];
};
interface StalkerEpgResponse {
js?: StalkerEpgEntry[] | { data?: StalkerEpgEntry[] };
js?:
| StalkerEpgEntry[]
| {
data?:
| StalkerEpgEntry[]
| Record<string, StalkerBulkEpgValue>;
}
| Record<string, StalkerBulkEpgValue>;
}
export interface StalkerEpgState {
bulkItvEpgByChannel: Record<string, EpgProgram[]>;
bulkItvEpgPlaylistId: string | null;
bulkItvEpgPeriodHours: number | null;
bulkItvEpgLoaded: boolean;
isLoadingBulkItvEpg: boolean;
}
const initialEpgState: StalkerEpgState = {
bulkItvEpgByChannel: {},
bulkItvEpgPlaylistId: null,
bulkItvEpgPeriodHours: null,
bulkItvEpgLoaded: false,
isLoadingBulkItvEpg: false,
};
const ACTIVE_EPG_FALLBACK_SIZE = 10;
/**
* EPG concern methods.
*/
export function withStalkerEpg() {
const logger = createLogger('withStalkerEpg');
return signalStoreFeature(
withState<StalkerEpgState>(initialEpgState),
withComputed((store) => {
const storeContext = store as unknown as EpgStoreContext;
return {
selectedItvEpgPrograms: computed(() => {
const selectedId = storeContext.selectedItvId?.();
if (!selectedId) {
return [];
}
return (
store.bulkItvEpgByChannel()[
normalizeStalkerEntityId(selectedId)
] ?? []
);
}),
};
}),
withMethods(
(
store,
@@ -41,61 +108,381 @@ export function withStalkerEpg() {
) => {
const storeContext = store as unknown as EpgStoreContext;
const requestEpg = async (
playlist: Playlist,
queryParams: Record<string, string>
): Promise<StalkerEpgResponse> => {
if (playlist.isFullStalkerPortal) {
return stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
}
return dataService.sendIpcEvent<StalkerEpgResponse>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
};
const fetchShortEpg = async (
channelId: number | string,
size: number
): Promise<EpgItem[]> => {
const playlist = storeContext.currentPlaylist();
if (!playlist) {
return [];
}
const response = await requestEpg(playlist, {
action: StalkerPortalActions.GetShortEpg,
type: 'itv',
ch_id: String(channelId),
size: String(size),
});
return extractShortEpgEntries(response)
.map((item) => toEpgItem(item, channelId))
.filter((item) => item.start && item.stop)
.sort(
(left, right) =>
getProgramTimestampMs(
left.start,
left.start_timestamp
) -
getProgramTimestampMs(
right.start,
right.start_timestamp
)
);
};
return {
async fetchChannelEpg(
channelId: number | string,
size = 10
size = ACTIVE_EPG_FALLBACK_SIZE
): Promise<EpgItem[]> {
try {
return await fetchShortEpg(channelId, size);
} catch (error) {
logger.error('Error loading short Stalker EPG', {
channelId,
size,
error,
});
return [];
}
},
async ensureBulkItvEpg(periodHours = 168): Promise<void> {
const playlist = storeContext.currentPlaylist();
if (!playlist) return [];
const queryParams: Record<string, string> = {
action: StalkerPortalActions.GetShortEpg,
type: 'itv',
ch_id: String(channelId),
size: String(size),
};
let response: StalkerEpgResponse;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response =
await dataService.sendIpcEvent<StalkerEpgResponse>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
if (!playlist?._id) {
patchState(store, initialEpgState);
return;
}
const epgData = Array.isArray(response?.js)
? response.js
: (response?.js?.data ?? []);
const items = Array.isArray(epgData) ? epgData : [];
const playlistId = String(playlist._id);
const shouldReuseCache =
store.bulkItvEpgLoaded() &&
store.bulkItvEpgPlaylistId() === playlistId &&
store.bulkItvEpgPeriodHours() === periodHours;
return items.map((item) => ({
id: String(item.id ?? ''),
epg_id: '',
title: item.name ?? '',
description: item.descr ?? '',
lang: '',
start: item.time ?? '',
end: item.time_to ?? '',
stop: item.time_to ?? '',
channel_id: String(item.ch_id ?? channelId),
start_timestamp: String(item.start_timestamp ?? ''),
stop_timestamp: String(item.stop_timestamp ?? ''),
}));
if (shouldReuseCache || store.isLoadingBulkItvEpg()) {
return;
}
if (store.bulkItvEpgPlaylistId() !== playlistId) {
patchState(store, initialEpgState);
}
patchState(store, {
isLoadingBulkItvEpg: true,
bulkItvEpgPlaylistId: playlistId,
bulkItvEpgPeriodHours: periodHours,
});
try {
const response = await requestEpg(playlist, {
action: StalkerPortalActions.GetEpgInfo,
type: 'itv',
period: String(periodHours),
});
const selectedChannelId =
storeContext.selectedItvId?.() ?? null;
const bulkPrograms = extractBulkEpgByChannel(
response,
selectedChannelId
);
patchState(store, {
bulkItvEpgByChannel: bulkPrograms,
bulkItvEpgLoaded: true,
isLoadingBulkItvEpg: false,
});
} catch (error) {
logger.warn('Bulk Stalker EPG unavailable', error);
patchState(store, {
bulkItvEpgByChannel: {},
bulkItvEpgLoaded: true,
isLoadingBulkItvEpg: false,
});
}
},
clearBulkItvEpgCache(): void {
patchState(store, initialEpgState);
},
};
}
)
);
}
function extractShortEpgEntries(response: StalkerEpgResponse): StalkerEpgEntry[] {
if (Array.isArray(response?.js)) {
return response.js;
}
const data = response?.js?.data;
return Array.isArray(data) ? data : [];
}
function extractBulkEpgByChannel(
response: StalkerEpgResponse,
selectedChannelId?: string | null
): Record<string, EpgProgram[]> {
const raw = response?.js;
const groupedEntries: Record<string, StalkerEpgEntry[]> = {};
const appendEntries = (
channelId: string | number | null | undefined,
entries: StalkerEpgEntry[]
) => {
const normalizedChannelId = normalizeOptionalEntityId(channelId);
if (!normalizedChannelId || entries.length === 0) {
return;
}
groupedEntries[normalizedChannelId] = entries;
};
const groupArrayEntries = (entries: StalkerEpgEntry[]) => {
for (const entry of entries) {
const entryChannelId =
entry.ch_id ?? selectedChannelId ?? null;
const normalizedChannelId = normalizeOptionalEntityId(
entryChannelId
);
if (!normalizedChannelId) {
continue;
}
groupedEntries[normalizedChannelId] ??= [];
groupedEntries[normalizedChannelId].push(entry);
}
};
if (Array.isArray(raw)) {
groupArrayEntries(raw);
} else {
const rawData =
raw && typeof raw === 'object' && 'data' in raw
? raw.data
: raw;
if (Array.isArray(rawData)) {
groupArrayEntries(rawData);
} else if (rawData && typeof rawData === 'object') {
for (const [channelId, value] of Object.entries(rawData)) {
appendEntries(channelId, extractEntriesFromBulkValue(value));
}
}
}
const normalizedPrograms: Record<string, EpgProgram[]> = {};
for (const [channelId, entries] of Object.entries(groupedEntries)) {
const programs = entriesToPrograms(entries, channelId);
if (programs.length > 0) {
normalizedPrograms[channelId] = programs;
}
}
return normalizedPrograms;
}
function extractEntriesFromBulkValue(value: unknown): StalkerEpgEntry[] {
if (Array.isArray(value)) {
return value;
}
if (!value || typeof value !== 'object') {
return [];
}
const recordValue = value as {
data?: StalkerEpgEntry[];
epg?: StalkerEpgEntry[];
items?: StalkerEpgEntry[];
};
if (Array.isArray(recordValue.data)) {
return recordValue.data;
}
if (Array.isArray(recordValue.epg)) {
return recordValue.epg;
}
if (Array.isArray(recordValue.items)) {
return recordValue.items;
}
return [];
}
function entriesToPrograms(
entries: StalkerEpgEntry[],
fallbackChannelId: string | number
): EpgProgram[] {
return entries
.map((entry) => toEpgProgram(entry, fallbackChannelId))
.filter((program): program is EpgProgram => program !== null)
.sort(
(left, right) =>
getProgramTimestampMs(left.start, left.startTimestamp) -
getProgramTimestampMs(right.start, right.startTimestamp)
);
}
function toEpgItem(
item: StalkerEpgEntry,
fallbackChannelId: string | number
): EpgItem {
const startRaw = item.time ?? item.start ?? '';
const stopRaw = item.time_to ?? item.stop ?? '';
const startTimestamp = getProgramTimestampSeconds(
startRaw,
item.start_timestamp
);
const stopTimestamp = getProgramTimestampSeconds(
stopRaw,
item.stop_timestamp
);
const start = toIsoString(startRaw, startTimestamp);
const stop = toIsoString(stopRaw, stopTimestamp);
return {
id: String(item.id ?? ''),
epg_id: '',
title: item.name ?? '',
description: item.descr ?? '',
lang: '',
start,
end: stop,
stop,
channel_id: String(item.ch_id ?? fallbackChannelId),
start_timestamp:
startTimestamp !== null ? String(startTimestamp) : '',
stop_timestamp: stopTimestamp !== null ? String(stopTimestamp) : '',
};
}
function toEpgProgram(
item: StalkerEpgEntry,
fallbackChannelId: string | number
): EpgProgram | null {
const epgItem = toEpgItem(item, fallbackChannelId);
if (!epgItem.start || !epgItem.stop) {
return null;
}
return {
start: epgItem.start,
stop: epgItem.stop,
channel: normalizeStalkerEntityId(epgItem.channel_id),
title: epgItem.title,
desc: epgItem.description || null,
category: item.category ?? null,
startTimestamp: parseInteger(epgItem.start_timestamp),
stopTimestamp: parseInteger(epgItem.stop_timestamp),
};
}
function toIsoString(
rawValue: string,
timestampSeconds: number | null
): string {
if (timestampSeconds !== null) {
return new Date(timestampSeconds * 1000).toISOString();
}
const normalized = String(rawValue ?? '').trim();
if (!normalized) {
return '';
}
const candidate = normalized.includes('T')
? normalized
: normalized.replace(' ', 'T');
const parsed = Date.parse(candidate);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
}
function getProgramTimestampSeconds(
rawValue: string,
timestampValue?: string | number | null
): number | null {
const parsedTimestamp = parseInteger(timestampValue);
if (parsedTimestamp !== null && parsedTimestamp > 0) {
return parsedTimestamp;
}
const normalized = String(rawValue ?? '').trim();
if (!normalized) {
return null;
}
const candidate = normalized.includes('T')
? normalized
: normalized.replace(' ', 'T');
const parsedDate = Date.parse(candidate);
return Number.isFinite(parsedDate)
? Math.floor(parsedDate / 1000)
: null;
}
function getProgramTimestampMs(
rawValue: string,
timestampValue?: string | number | null
): number {
const timestampSeconds = getProgramTimestampSeconds(
rawValue,
timestampValue
);
if (timestampSeconds !== null) {
return timestampSeconds * 1000;
}
return Number.POSITIVE_INFINITY;
}
function parseInteger(value: unknown): number | null {
const parsed = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeOptionalEntityId(
value: string | number | null | undefined
): string | null {
if (value === null || value === undefined || value === '') {
return null;
}
return normalizeStalkerEntityId(value);
}
@@ -0,0 +1,116 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MockPipe } from 'ng-mocks';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { EpgProgram } from 'shared-interfaces';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { StalkerCollectionChannelsListComponent } from './stalker-collection-channels-list.component';
@Component({
selector: 'app-channel-list-item',
standalone: true,
template: '',
})
class StubChannelListItemComponent {
readonly name = input('');
readonly logo = input<string | null | undefined>(null);
readonly selected = input(false);
readonly epgProgram = input<unknown>(null);
readonly progressPercentage = input(0);
readonly showFavoriteButton = input(false);
readonly showProgramInfoButton = input(false);
readonly isFavorite = input(false);
readonly clicked = output<void>();
readonly favoriteToggled = output<void>();
}
describe('StalkerCollectionChannelsListComponent', () => {
let fixture: ComponentFixture<StalkerCollectionChannelsListComponent>;
let component: StalkerCollectionChannelsListComponent;
const bulkItvEpgByChannel = signal<Record<string, EpgProgram[]>>({});
const stalkerStore = {
bulkItvEpgByChannel,
};
beforeEach(async () => {
bulkItvEpgByChannel.set({});
await TestBed.configureTestingModule({
imports: [StalkerCollectionChannelsListComponent],
providers: [{ provide: StalkerStore, useValue: stalkerStore }],
})
.overrideComponent(StalkerCollectionChannelsListComponent, {
remove: {
imports: [ChannelListItemComponent, TranslatePipe],
},
add: {
imports: [
StubChannelListItemComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerCollectionChannelsListComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('items', [
{
id: '10001',
name: 'Alpha TV',
o_name: 'Alpha TV',
logo: 'alpha.png',
},
{
id: '10002',
name: 'Beta TV',
o_name: 'Beta TV',
logo: 'beta.png',
},
]);
});
afterEach(() => {
fixture?.destroy();
});
it('keeps row previews empty until bulk epg has been loaded', () => {
fixture.detectChanges();
expect(component.epgPrograms.size).toBe(0);
expect(component.currentProgramsProgress.size).toBe(0);
});
it('derives row previews from cached bulk epg', () => {
bulkItvEpgByChannel.set({
'10001': [buildProgram('10001', 'Current Show')],
'10002': [buildProgram('10002', 'Other Show')],
});
fixture.detectChanges();
expect(component.epgPrograms.get('10001')?.title).toBe('Current Show');
expect(component.epgPrograms.get('10002')?.title).toBe('Other Show');
});
});
function buildProgram(channelId: string, title: string): EpgProgram {
const startTimestamp = Math.floor((Date.now() - 10 * 60 * 1000) / 1000);
const stopTimestamp = startTimestamp + 30 * 60;
return {
start: new Date(startTimestamp * 1000).toISOString(),
stop: new Date(stopTimestamp * 1000).toISOString(),
channel: channelId,
title,
desc: `${title} description`,
category: null,
startTimestamp,
stopTimestamp,
};
}
@@ -1,4 +1,5 @@
import {
ChangeDetectorRef,
ChangeDetectionStrategy,
Component,
computed,
@@ -12,7 +13,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgProgram } from 'shared-interfaces';
import { StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@@ -33,6 +34,7 @@ export class StalkerCollectionChannelsListComponent {
readonly favoriteToggled = output<StalkerVodSource>();
private readonly stalkerStore = inject(StalkerStore);
private readonly cdr = inject(ChangeDetectorRef);
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
readonly searchString = signal('');
readonly filteredItems = computed(() => {
@@ -50,19 +52,12 @@ export class StalkerCollectionChannelsListComponent {
readonly epgPrograms = new Map<string | number, EpgProgram>();
readonly currentProgramsProgress = new Map<string | number, number>();
private readonly requestedChannels = new Set<string | number>();
constructor() {
effect(() => {
const items = this.items();
if (!items.length) {
this.epgPrograms.clear();
this.currentProgramsProgress.clear();
this.requestedChannels.clear();
return;
}
void this.loadEpgPreviews(items);
const bulkProgramsByChannel = this.stalkerStore.bulkItvEpgByChannel();
this.syncBulkEpgPreviews(items, bulkProgramsByChannel);
});
}
@@ -82,60 +77,59 @@ export class StalkerCollectionChannelsListComponent {
return this.favoriteIds().get(normalizeStalkerEntityId(item.id)) ?? false;
}
private async loadEpgPreviews(items: StalkerVodSource[]): Promise<void> {
const newItems = items.filter((item) => {
const id = normalizeStalkerEntityId(item.id);
return id && !this.requestedChannels.has(id);
});
private syncBulkEpgPreviews(
items: StalkerVodSource[],
bulkProgramsByChannel: Record<string, EpgProgram[]>
): void {
this.epgPrograms.clear();
this.currentProgramsProgress.clear();
if (!newItems.length) {
if (items.length === 0 || Object.keys(bulkProgramsByChannel).length === 0) {
this.cdr.markForCheck();
return;
}
for (const item of newItems) {
this.requestedChannels.add(normalizeStalkerEntityId(item.id));
}
const batchSize = 3;
for (let i = 0; i < newItems.length; i += batchSize) {
const batch = newItems.slice(i, i + batchSize);
await Promise.all(
batch.map((item) => this.loadSingleEpgPreview(item.id))
for (const item of items) {
const channelId = normalizeStalkerEntityId(item.id);
const currentProgram = this.findCurrentProgram(
bulkProgramsByChannel[channelId] ?? []
);
if (i + batchSize < newItems.length) {
await new Promise((resolve) => setTimeout(resolve, 150));
}
}
}
private async loadSingleEpgPreview(channelId: number | string): Promise<void> {
try {
const items = await this.stalkerStore.fetchChannelEpg(channelId, 1);
if (!items.length) {
return;
if (!currentProgram) {
continue;
}
const id = normalizeStalkerEntityId(channelId);
const program = this.toPreviewProgram(items[0], id);
this.epgPrograms.set(id, program);
this.updateProgramProgress(id, items[0]);
} catch {
// Ignore preview failures for collection live lists.
this.epgPrograms.set(channelId, currentProgram);
this.updateProgramProgress(channelId, currentProgram);
}
this.cdr.markForCheck();
}
private updateProgramProgress(
channelId: string | number,
item: EpgItem
program: EpgProgram
): void {
const now = Date.now() / 1000;
const start = parseInt(item.start_timestamp, 10);
const end = parseInt(item.stop_timestamp, 10);
const startMs = this.getProgramTimestampMs(
program.start,
program.startTimestamp
);
const stopMs = this.getProgramTimestampMs(
program.stop,
program.stopTimestamp
);
const nowMs = Date.now();
if (start && end && now >= start && now <= end) {
if (
Number.isFinite(startMs) &&
Number.isFinite(stopMs) &&
nowMs >= startMs &&
nowMs <= stopMs &&
stopMs > startMs
) {
this.currentProgramsProgress.set(
channelId,
((now - start) / (end - start)) * 100
((nowMs - startMs) / (stopMs - startMs)) * 100
);
return;
}
@@ -143,17 +137,35 @@ export class StalkerCollectionChannelsListComponent {
this.currentProgramsProgress.delete(channelId);
}
private toPreviewProgram(
item: EpgItem,
channelId: string | number
): EpgProgram {
return {
start: item.start,
stop: item.stop || item.end,
channel: String(channelId),
title: item.title,
desc: item.description || null,
category: null,
};
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null {
const nowMs = Date.now();
return (
programs.find((program) => {
const startMs = this.getProgramTimestampMs(
program.start,
program.startTimestamp
);
const stopMs = this.getProgramTimestampMs(
program.stop,
program.stopTimestamp
);
return nowMs >= startMs && nowMs <= stopMs;
}) ?? null
);
}
private getProgramTimestampMs(
rawDate: string,
timestamp?: number | null
): number {
const parsedTimestamp = Number(timestamp);
if (Number.isFinite(parsedTimestamp) && parsedTimestamp > 0) {
return parsedTimestamp * 1000;
}
const parsedDate = Date.parse(rawDate);
return Number.isFinite(parsedDate) ? parsedDate : Number.POSITIVE_INFINITY;
}
}
@@ -84,18 +84,16 @@
}
<div class="epg">
<div class="epg-content">
<app-epg-view [epgItems]="epgItems()" />
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
}
@if (hasMoreEpg() && !isLoadingEpg()) {
<div class="load-more-epg">
<button mat-button (click)="loadMoreEpg()">
{{ 'CHANNELS.LOAD_MORE' | translate }}
</button>
</div>
} @else {
<app-epg-list
[controlledChannel]="controlledChannel()"
[controlledPrograms]="activeEpgPrograms()"
[controlledArchiveDays]="0"
/>
}
</div>
</div>
@@ -0,0 +1,323 @@
import { Component, Directive, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MockPipe } from 'ng-mocks';
import { of } from 'rxjs';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PORTAL_PLAYER, ResizableDirective } from '@iptvnator/portal/shared/util';
import { EpgListComponent } from '@iptvnator/ui/epg';
import { WebPlayerViewComponent } from 'shared-portals';
import { ChannelListItemComponent } from 'components';
import { PlaylistsService } from 'services';
import { EpgProgram } from 'shared-interfaces';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
@Component({
selector: 'app-channel-list-item',
standalone: true,
template: '',
})
class StubChannelListItemComponent {
readonly name = input('');
readonly logo = input<string | null | undefined>(null);
readonly selected = input(false);
readonly epgProgram = input<unknown>(null);
readonly progressPercentage = input(0);
readonly showFavoriteButton = input(false);
readonly showProgramInfoButton = input(false);
readonly isFavorite = input(false);
readonly clicked = output<void>();
readonly favoriteToggled = output<void>();
}
@Component({
selector: 'app-web-player-view',
standalone: true,
template: '',
})
class StubWebPlayerViewComponent {
readonly streamUrl = input('');
}
@Component({
selector: 'app-epg-list',
standalone: true,
template: '',
})
class StubEpgListComponent {
readonly controlledChannel = input<unknown>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
}
@Component({
selector: 'app-portal-empty-state',
standalone: true,
template: '',
})
class StubPortalEmptyStateComponent {
readonly icon = input('');
readonly message = input('');
}
@Directive({
selector: '[appResizable]',
standalone: true,
})
class StubResizableDirective {}
describe('StalkerLiveStreamLayoutComponent', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
let component: StalkerLiveStreamLayoutComponent;
let fetchChannelEpg: jest.Mock;
let ensureBulkItvEpg: jest.Mock;
let resolveItvPlayback: jest.Mock;
const playlist = signal({
_id: 'playlist-1',
title: 'Demo Stalker',
});
const selectedCategoryId = signal<string | null>('1001');
const searchPhrase = signal('');
const itvChannels = signal([
{
id: '10001',
cmd: 'ffrt4://itv/10001',
name: 'Alpha TV',
o_name: 'Alpha TV',
logo: 'alpha.png',
},
{
id: '10002',
cmd: 'ffrt4://itv/10002',
name: 'Beta TV',
o_name: 'Beta TV',
logo: 'beta.png',
},
]);
const selectedItvId = signal<string | undefined>('10001');
const selectedItem = signal<{
id: string;
cmd: string;
name: string;
o_name: string;
logo: string;
} | null>(itvChannels()[0]);
const selectedItvEpgPrograms = signal<EpgProgram[]>([]);
const bulkItvEpgByChannel = signal<Record<string, EpgProgram[]>>({});
const bulkItvEpgLoaded = signal(false);
const bulkItvEpgPlaylistId = signal<string | null>(null);
const bulkItvEpgPeriodHours = signal<number | null>(null);
const isLoadingBulkItvEpg = signal(false);
const stalkerStore = {
getSelectedCategoryName: signal('News'),
itvChannels,
searchPhrase,
hasMoreChannels: signal(false),
selectedItvId,
currentPlaylist: playlist,
selectedItvEpgPrograms,
bulkItvEpgByChannel,
bulkItvEpgLoaded,
bulkItvEpgPlaylistId,
bulkItvEpgPeriodHours,
isLoadingBulkItvEpg,
selectedCategoryId,
selectedItem,
selectedContentType: signal<'itv' | 'vod' | 'series'>('itv'),
page: signal(0),
setItvChannels: jest.fn(),
setPage: jest.fn(),
setSelectedItem: jest.fn((item) => {
selectedItem.set(item);
selectedItvId.set(String(item.id));
selectedItvEpgPrograms.set(
bulkItvEpgByChannel()[String(item.id)] ?? []
);
}),
resolveItvPlayback: jest.fn(),
fetchChannelEpg: jest.fn(),
ensureBulkItvEpg: jest.fn(),
clearBulkItvEpgCache: jest.fn(() => {
bulkItvEpgByChannel.set({});
bulkItvEpgLoaded.set(false);
bulkItvEpgPlaylistId.set(null);
bulkItvEpgPeriodHours.set(null);
selectedItvEpgPrograms.set([]);
}),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
};
const playlistService = {
getPortalFavorites: jest.fn(() => of([])),
};
const portalPlayer = {
isEmbeddedPlayer: jest.fn(() => true),
openResolvedPlayback: jest.fn(),
};
beforeEach(async () => {
fetchChannelEpg = stalkerStore.fetchChannelEpg;
ensureBulkItvEpg = stalkerStore.ensureBulkItvEpg;
resolveItvPlayback = stalkerStore.resolveItvPlayback;
selectedCategoryId.set('1001');
searchPhrase.set('');
selectedItvId.set('10001');
selectedItem.set(itvChannels()[0]);
selectedItvEpgPrograms.set([]);
bulkItvEpgByChannel.set({});
bulkItvEpgLoaded.set(false);
bulkItvEpgPlaylistId.set(null);
bulkItvEpgPeriodHours.set(null);
isLoadingBulkItvEpg.set(false);
resolveItvPlayback.mockReset();
resolveItvPlayback.mockResolvedValue({
streamUrl: 'https://example.com/alpha.m3u8',
});
fetchChannelEpg.mockReset();
fetchChannelEpg.mockResolvedValue([]);
ensureBulkItvEpg.mockReset();
ensureBulkItvEpg.mockImplementation(async () => {
const bulkPrograms = {
'10001': [buildProgram('10001', 'Current Show')],
'10002': [buildProgram('10002', 'Next Channel Show')],
};
bulkItvEpgByChannel.set(bulkPrograms);
bulkItvEpgLoaded.set(true);
bulkItvEpgPlaylistId.set('playlist-1');
bulkItvEpgPeriodHours.set(168);
selectedItvEpgPrograms.set(
bulkPrograms[selectedItvId() ?? ''] ?? []
);
});
stalkerStore.setItvChannels.mockClear();
stalkerStore.setPage.mockClear();
stalkerStore.setSelectedItem.mockClear();
stalkerStore.clearBulkItvEpgCache.mockClear();
await TestBed.configureTestingModule({
imports: [StalkerLiveStreamLayoutComponent, NoopAnimationsModule],
providers: [
{ provide: StalkerStore, useValue: stalkerStore },
{ provide: PlaylistsService, useValue: playlistService },
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
{
provide: TranslateService,
useValue: {
instant: jest.fn((value: string) => value),
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
],
})
.overrideComponent(StalkerLiveStreamLayoutComponent, {
remove: {
imports: [
ChannelListItemComponent,
EpgListComponent,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
},
add: {
imports: [
StubChannelListItemComponent,
StubEpgListComponent,
StubPortalEmptyStateComponent,
StubResizableDirective,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
StubWebPlayerViewComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
component = fixture.componentInstance;
});
afterEach(() => {
fixture?.destroy();
});
it('renders the controlled epg list and removes the load-more button', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('app-epg-list')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('.load-more-epg')
).toBeNull();
});
it('keeps row previews empty before bulk epg is loaded', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(component.epgPreviewPrograms.size).toBe(0);
expect(fetchChannelEpg).not.toHaveBeenCalled();
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
});
it('derives row previews from cached bulk epg after first playback', async () => {
fixture.detectChanges();
await component.playChannel(itvChannels()[0]);
await fixture.whenStable();
fixture.detectChanges();
expect(component.epgPreviewPrograms.get('10001')?.title).toBe(
'Current Show'
);
expect(component.epgPreviewPrograms.get('10002')?.title).toBe(
'Next Channel Show'
);
expect(fetchChannelEpg).not.toHaveBeenCalled();
});
it('ensures bulk EPG only once across channel switches for the same playlist', async () => {
fixture.detectChanges();
await component.playChannel(itvChannels()[0]);
await fixture.whenStable();
await component.playChannel(itvChannels()[1]);
await fixture.whenStable();
expect(ensureBulkItvEpg).toHaveBeenCalledTimes(1);
expect(ensureBulkItvEpg).toHaveBeenCalledWith(168);
});
});
function buildProgram(channelId: string, title: string): EpgProgram {
const startTimestamp = Math.floor((Date.now() - 10 * 60 * 1000) / 1000);
const stopTimestamp = startTimestamp + 30 * 60;
return {
start: new Date(startTimestamp * 1000).toISOString(),
stop: new Date(stopTimestamp * 1000).toISOString(),
channel: channelId,
title,
desc: `${title} description`,
category: null,
startTimestamp,
stopTimestamp,
};
}
@@ -6,13 +6,11 @@ import {
effect,
ElementRef,
inject,
NgZone,
OnDestroy,
signal,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
@@ -21,22 +19,21 @@ import {
ResizableDirective,
} from 'components';
import { PlaylistsService } from 'services';
import { EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { Channel, EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgListComponent } from '@iptvnator/ui/epg';
import { WebPlayerViewComponent } from 'shared-portals';
import {
PORTAL_PLAYER,
createLogger,
getAdjacentChannelItem,
getChannelItemByNumber,
} from '@iptvnator/portal/shared/util';
import {
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
StalkerFavoriteItem,
StalkerItvChannel,
normalizeStalkerEntityId,
StalkerStore,
normalizeStalkerEntityId,
} from '@iptvnator/portal/stalker/data-access';
@Component({
@@ -45,8 +42,7 @@ import {
styleUrls: ['./stalker-live-stream-layout.component.scss'],
imports: [
ChannelListItemComponent,
EpgViewComponent,
MatButton,
EpgListComponent,
MatProgressSpinnerModule,
PortalEmptyStateComponent,
ResizableDirective,
@@ -96,18 +92,58 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
streamUrl = '';
/** EPG */
readonly epgItems = signal<EpgItem[]>([]);
readonly isLoadingEpg = signal(false);
readonly hasMoreEpg = signal(false);
private epgPageSize = 10;
private epgChannelId: number | string | null = null;
readonly fallbackEpgPrograms = signal<EpgProgram[]>([]);
readonly isLoadingFallbackEpg = signal(false);
readonly activeEpgPrograms = computed(() => {
const bulkPrograms = this.stalkerStore.selectedItvEpgPrograms();
return bulkPrograms.length > 0
? bulkPrograms
: this.fallbackEpgPrograms();
});
readonly currentProgram = computed(() =>
this.findCurrentProgram(this.activeEpgPrograms())
);
readonly controlledChannel = computed<Channel | null>(() => {
const selectedType = this.stalkerStore.selectedContentType();
const selectedItem = this.stalkerStore.selectedItem();
if (selectedType !== 'itv' || !selectedItem?.id) {
return null;
}
const channelId = normalizeStalkerEntityId(selectedItem.id);
const channelName = selectedItem.o_name || selectedItem.name || '';
return {
id: channelId,
name: channelName,
url: this.streamUrl || String(selectedItem.cmd ?? ''),
group: { title: '' },
tvg: {
id: channelId,
name: channelName,
url: '',
logo: selectedItem.logo ?? '',
rec: '',
},
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
};
});
readonly isLoadingEpg = computed(
() =>
this.stalkerStore.isLoadingBulkItvEpg() ||
this.isLoadingFallbackEpg()
);
/** Channel list EPG preview */
readonly epgPreviewPrograms = new Map<string | number, EpgProgram>();
readonly currentProgramsProgress = new Map<string | number, number>();
private readonly requestedEpgChannels = new Set<string | number>();
private readonly cdr = inject(ChangeDetectorRef);
private readonly ngZone = inject(NgZone);
/** Favorites */
readonly favorites = new Map<string | number, boolean>();
@@ -117,6 +153,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private scrollListener: (() => void) | null = null;
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
private epgLoadRequestId = 0;
private lastPlaylistId: string | null | undefined = undefined;
constructor() {
// Load favorites for current playlist
@@ -140,9 +178,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.stalkerStore.setItvChannels([]);
this.stalkerStore.setPage(0);
this.clearEpgPreviewMaps();
this.epgLoadRequestId += 1;
this.fallbackEpgPrograms.set([]);
this.isLoadingFallbackEpg.set(false);
});
// Reset loading state when channels load + check viewport fill + load EPG previews
// Reset loading state when channels load and keep preview data in sync with bulk EPG.
effect(() => {
const channels = this.visibleChannels();
if (channels.length > 0) {
@@ -150,8 +191,22 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
if (!this.searchTerm()) {
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
}
this.loadEpgPreviewsForChannels(channels);
}
this.syncBulkEpgPreviews(channels);
});
effect(() => {
const playlistId = this.stalkerStore.currentPlaylist()?._id ?? null;
if (playlistId === this.lastPlaylistId) {
return;
}
this.lastPlaylistId = playlistId;
this.epgLoadRequestId += 1;
this.fallbackEpgPrograms.set([]);
this.isLoadingFallbackEpg.set(false);
this.stalkerStore.clearBulkItvEpgCache();
});
// Setup scroll listener when container becomes available
@@ -170,7 +225,6 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const selectedItem = this.stalkerStore.selectedItem();
const selectedType = this.stalkerStore.selectedContentType();
const channels = this.visibleChannels();
const epgItems = this.epgItems();
if (selectedType !== 'itv' || !selectedItem?.id) {
window.electron.updateRemoteControlStatus({
@@ -184,7 +238,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const currentIndex = channels.findIndex(
(item) => Number(item.id) === Number(selectedItem.id)
);
const currentProgram = epgItems?.[0];
const currentProgram = this.currentProgram();
window.electron.updateRemoteControlStatus({
portal: 'stalker',
@@ -193,7 +247,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
epgTitle: currentProgram?.title,
epgStart: currentProgram?.start,
epgEnd: currentProgram?.end,
epgEnd: currentProgram?.stop,
supportsVolume: false,
});
});
@@ -237,8 +291,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
try {
const playback = await this.stalkerStore.resolveItvPlayback(item);
this.loadEpgForChannel(item.id);
void this.loadEpgForChannel(item);
if (this.usesEmbeddedPlayer()) {
this.streamUrl = playback.streamUrl;
@@ -279,104 +332,129 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.stalkerStore.setPage(nextPage);
}
async loadMoreEpg() {
if (!this.epgChannelId || this.isLoadingEpg()) return;
this.epgPageSize += 10;
this.isLoadingEpg.set(true);
private async loadEpgForChannel(item: StalkerItvChannel) {
const requestId = ++this.epgLoadRequestId;
const normalizedChannelId = normalizeStalkerEntityId(item.id);
const playlistId = this.stalkerStore.currentPlaylist()?._id ?? null;
const shouldEnsureBulk =
!this.stalkerStore.bulkItvEpgLoaded() ||
this.stalkerStore.bulkItvEpgPlaylistId() !== playlistId ||
this.stalkerStore.bulkItvEpgPeriodHours() !== 168;
this.fallbackEpgPrograms.set([]);
this.isLoadingFallbackEpg.set(false);
try {
const items = await this.stalkerStore.fetchChannelEpg(
this.epgChannelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.hasMoreEpg.set(false);
} finally {
this.isLoadingEpg.set(false);
}
}
private async loadEpgForChannel(channelId: number | string) {
this.epgChannelId = channelId;
this.epgPageSize = 10;
this.isLoadingEpg.set(true);
this.epgItems.set([]);
this.hasMoreEpg.set(false);
try {
const items = await this.stalkerStore.fetchChannelEpg(
channelId,
this.epgPageSize
);
this.epgItems.set(items);
this.hasMoreEpg.set(items.length >= this.epgPageSize);
} catch {
this.epgItems.set([]);
} finally {
this.isLoadingEpg.set(false);
}
}
private async loadEpgPreviewsForChannels(channels: StalkerItvChannel[]) {
const newChannels = channels.filter(
(ch) =>
ch.id &&
!this.requestedEpgChannels.has(normalizeStalkerEntityId(ch.id))
);
if (newChannels.length === 0) return;
// Mark all as requested immediately to avoid duplicates
for (const ch of newChannels) {
this.requestedEpgChannels.add(normalizeStalkerEntityId(ch.id));
}
// Process in batches of 3 with a small delay between batches
const batchSize = 3;
for (let i = 0; i < newChannels.length; i += batchSize) {
const batch = newChannels.slice(i, i + batchSize);
await Promise.all(
batch.map((ch) => this.loadSingleEpgPreview(ch.id))
);
// Re-enter Angular zone to trigger change detection for OnPush
this.ngZone.run(() => this.cdr.markForCheck());
// Small delay between batches to avoid overwhelming the portal
if (i + batchSize < newChannels.length) {
await new Promise((r) => setTimeout(r, 150));
}
}
}
private async loadSingleEpgPreview(channelId: number | string) {
try {
const items = await this.stalkerStore.fetchChannelEpg(channelId, 1);
if (items.length > 0) {
const program = items[0];
const id = normalizeStalkerEntityId(channelId);
this.epgPreviewPrograms.set(
id,
this.toPreviewProgram(program, id)
);
const now = Date.now() / 1000;
const start = parseInt(program.start_timestamp, 10);
const end = parseInt(program.stop_timestamp, 10);
if (start && end && now >= start && now <= end) {
const progress = ((now - start) / (end - start)) * 100;
this.currentProgramsProgress.set(id, progress);
} else {
this.currentProgramsProgress.delete(id);
if (shouldEnsureBulk) {
await this.stalkerStore.ensureBulkItvEpg(168);
if (
!this.isCurrentEpgRequest(
requestId,
normalizedChannelId
)
) {
return;
}
}
} catch {
// Silently skip — channel just won't show EPG preview
if (this.stalkerStore.selectedItvEpgPrograms().length > 0) {
return;
}
this.isLoadingFallbackEpg.set(true);
const fallbackItems = await this.stalkerStore.fetchChannelEpg(
item.id
);
if (
!this.isCurrentEpgRequest(requestId, normalizedChannelId)
) {
return;
}
this.fallbackEpgPrograms.set(
fallbackItems.map((epgItem) =>
this.toProgram(epgItem, normalizedChannelId)
)
);
} catch (error) {
this.logger.warn('Failed to load Stalker live EPG', error);
if (
this.isCurrentEpgRequest(requestId, normalizedChannelId)
) {
this.fallbackEpgPrograms.set([]);
}
} finally {
if (
this.isCurrentEpgRequest(requestId, normalizedChannelId)
) {
this.isLoadingFallbackEpg.set(false);
}
}
}
private clearEpgPreviewMaps() {
this.epgPreviewPrograms.clear();
this.currentProgramsProgress.clear();
this.requestedEpgChannels.clear();
}
private syncBulkEpgPreviews(channels: StalkerItvChannel[]): void {
this.clearEpgPreviewMaps();
const bulkProgramsByChannel = this.stalkerStore.bulkItvEpgByChannel();
if (
channels.length === 0 ||
Object.keys(bulkProgramsByChannel).length === 0
) {
this.cdr.markForCheck();
return;
}
for (const channel of channels) {
const channelId = normalizeStalkerEntityId(channel.id);
const currentProgram = this.findCurrentProgram(
bulkProgramsByChannel[channelId] ?? []
);
if (!currentProgram) {
continue;
}
this.epgPreviewPrograms.set(channelId, currentProgram);
this.updateProgramProgress(channelId, currentProgram);
}
this.cdr.markForCheck();
}
private updateProgramProgress(
channelId: string | number,
program: EpgProgram
): void {
const startMs = this.getProgramTimestampMs(
program.start,
program.startTimestamp
);
const stopMs = this.getProgramTimestampMs(
program.stop,
program.stopTimestamp
);
const nowMs = Date.now();
if (
Number.isFinite(startMs) &&
Number.isFinite(stopMs) &&
nowMs >= startMs &&
nowMs <= stopMs &&
stopMs > startMs
) {
this.currentProgramsProgress.set(
channelId,
((nowMs - startMs) / (stopMs - startMs)) * 100
);
return;
}
this.currentProgramsProgress.delete(channelId);
}
private setupScrollListener() {
@@ -419,7 +497,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
}
private toPreviewProgram(
private toProgram(
item: EpgItem,
channelId: string | number
): EpgProgram {
@@ -430,9 +508,68 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
title: item.title,
desc: item.description || null,
category: null,
startTimestamp: this.toTimestamp(item.start_timestamp, item.start),
stopTimestamp: this.toTimestamp(
item.stop_timestamp,
item.stop || item.end
),
};
}
private toTimestamp(
rawTimestamp: string | number | null | undefined,
rawDate: string
): number | null {
const timestamp = Number.parseInt(String(rawTimestamp ?? ''), 10);
if (Number.isFinite(timestamp) && timestamp > 0) {
return timestamp;
}
const parsedDate = Date.parse(rawDate);
return Number.isFinite(parsedDate)
? Math.floor(parsedDate / 1000)
: null;
}
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null {
const now = Date.now();
return (
programs.find((program) => {
const start = this.getProgramTimestampMs(
program.start,
program.startTimestamp
);
const stop = this.getProgramTimestampMs(
program.stop,
program.stopTimestamp
);
return start !== null && stop !== null && now >= start && now < stop;
}) ?? null
);
}
private getProgramTimestampMs(
rawDate: string,
rawTimestamp?: number | null
): number | null {
if (Number.isFinite(rawTimestamp) && Number(rawTimestamp) > 0) {
return Number(rawTimestamp) * 1000;
}
const parsedDate = Date.parse(rawDate);
return Number.isFinite(parsedDate) ? parsedDate : null;
}
private isCurrentEpgRequest(
requestId: number,
normalizedChannelId: string
): boolean {
return (
requestId === this.epgLoadRequestId &&
this.selectedChannelId() === normalizedChannelId
);
}
private handleRemoteChannelChange(direction: 'up' | 'down'): void {
const activeItem = this.stalkerStore.selectedItem();
if (!activeItem?.id) {