mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
bf427f7226
commit
545dfd8c67
14 files changed
+1542
-255
No files matched your search
@@ -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}` } });
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+199
@@ -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,
|
||||
};
|
||||
}
|
||||
+434
-47
@@ -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);
|
||||
}
|
||||
+116
@@ -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,
|
||||
};
|
||||
}
|
||||
+71
-59
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-8
@@ -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>
|
||||
|
||||
+323
@@ -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,
|
||||
};
|
||||
}
|
||||
+251
-114
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user