diff --git a/apps/remote-control-web/project.json b/apps/remote-control-web/project.json index 9479337a7..437e5542f 100644 --- a/apps/remote-control-web/project.json +++ b/apps/remote-control-web/project.json @@ -4,6 +4,11 @@ "projectType": "application", "prefix": "app", "sourceRoot": "apps/remote-control-web/src", + "cli": { + "cache": { + "enabled": false + } + }, "tags": [], "targets": { "build": { diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index 842e7ae24..321e91769 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -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; } diff --git a/apps/stalker-mock-server/src/app/handlers/get-epg-info.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-epg-info.handler.ts new file mode 100644 index 000000000..7c89386fa --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-epg-info.handler.ts @@ -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, + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts index 5e27a8872..441631f1d 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts @@ -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), }, }); } diff --git a/apps/stalker-mock-server/src/app/routes/dispatch.ts b/apps/stalker-mock-server/src/app/routes/dispatch.ts index b96ba182f..507ad1bf8 100644 --- a/apps/stalker-mock-server/src/app/routes/dispatch.ts +++ b/apps/stalker-mock-server/src/app/routes/dispatch.ts @@ -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}` } }); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index af4a5068a..faebaab4c 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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 }) => { diff --git a/apps/web/project.json b/apps/web/project.json index f7fc73009..e5de67e89 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -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", diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts new file mode 100644 index 000000000..0602fa4ae --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts @@ -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; + let dataService: { + sendIpcEvent: jest.Mock, unknown[]>; + }; + let stalkerSessionService: { + makeAuthenticatedRequest: jest.Mock, 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, + }; +} diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts index 0764128cb..f0cba5cf8 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts @@ -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; + } + | Record; } +export interface StalkerEpgState { + bulkItvEpgByChannel: Record; + 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(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 + ): Promise => { + if (playlist.isFullStalkerPortal) { + return stalkerSession.makeAuthenticatedRequest( + playlist, + queryParams + ); + } + + return dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: playlist.portalUrl, + macAddress: playlist.macAddress, + params: queryParams, + } + ); + }; + + const fetchShortEpg = async ( + channelId: number | string, + size: number + ): Promise => { + 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 { + try { + return await fetchShortEpg(channelId, size); + } catch (error) { + logger.error('Error loading short Stalker EPG', { + channelId, + size, + error, + }); + return []; + } + }, + + async ensureBulkItvEpg(periodHours = 168): Promise { const playlist = storeContext.currentPlaylist(); - if (!playlist) return []; - - const queryParams: Record = { - 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( - 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 { + const raw = response?.js; + const groupedEntries: Record = {}; + + 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 = {}; + + 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); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.spec.ts new file mode 100644 index 000000000..96d56d510 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.spec.ts @@ -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(null); + readonly selected = input(false); + readonly epgProgram = input(null); + readonly progressPercentage = input(0); + readonly showFavoriteButton = input(false); + readonly showProgramInfoButton = input(false); + readonly isFavorite = input(false); + readonly clicked = output(); + readonly favoriteToggled = output(); +} + +describe('StalkerCollectionChannelsListComponent', () => { + let fixture: ComponentFixture; + let component: StalkerCollectionChannelsListComponent; + + const bulkItvEpgByChannel = signal>({}); + 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, + }; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts index 539219850..c67a4f259 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts @@ -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(); 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(); readonly currentProgramsProgress = new Map(); - private readonly requestedChannels = new Set(); 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 { - const newItems = items.filter((item) => { - const id = normalizeStalkerEntityId(item.id); - return id && !this.requestedChannels.has(id); - }); + private syncBulkEpgPreviews( + items: StalkerVodSource[], + bulkProgramsByChannel: Record + ): 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 { - 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; } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 4529927ac..3b8280526 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -84,18 +84,16 @@ }
- @if (isLoadingEpg()) {
- } - @if (hasMoreEpg() && !isLoadingEpg()) { -
- -
+ } @else { + }
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts new file mode 100644 index 000000000..c816e2ec1 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -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(null); + readonly selected = input(false); + readonly epgProgram = input(null); + readonly progressPercentage = input(0); + readonly showFavoriteButton = input(false); + readonly showProgramInfoButton = input(false); + readonly isFavorite = input(false); + readonly clicked = output(); + readonly favoriteToggled = output(); +} + +@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(null); + readonly controlledPrograms = input(null); + readonly controlledArchiveDays = input(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; + 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('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('10001'); + const selectedItem = signal<{ + id: string; + cmd: string; + name: string; + o_name: string; + logo: string; + } | null>(itvChannels()[0]); + const selectedItvEpgPrograms = signal([]); + const bulkItvEpgByChannel = signal>({}); + const bulkItvEpgLoaded = signal(false); + const bulkItvEpgPlaylistId = signal(null); + const bulkItvEpgPeriodHours = signal(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, + }; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 40bb6596d..5c5dc5685 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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([]); - readonly isLoadingEpg = signal(false); - readonly hasMoreEpg = signal(false); - private epgPageSize = 10; - private epgChannelId: number | string | null = null; + readonly fallbackEpgPrograms = signal([]); + 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(() => { + 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(); readonly currentProgramsProgress = new Map(); - private readonly requestedEpgChannels = new Set(); private readonly cdr = inject(ChangeDetectorRef); - private readonly ngZone = inject(NgZone); /** Favorites */ readonly favorites = new Map(); @@ -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) {