diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts new file mode 100644 index 000000000..37eaa266c --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts @@ -0,0 +1,144 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + EPG_GUIDE_SOURCE, + EpgGuideChannel, + EpgGuideSource, +} from './epg-guide-source'; +import { + EPG_GUIDE_LOAD_CHUNK, + EpgGuideProgramsService, +} from './epg-guide-programs.service'; + +function channel(id: string, epgKey: string | null = id): EpgGuideChannel { + return { id, number: 1, name: id, logoUrl: null, epgKey }; +} + +function programFor(channelId: string): EpgProgram { + return { + start: '2026-09-06T16:00:00.000Z', + stop: '2026-09-06T17:00:00.000Z', + channel: channelId, + title: `${channelId} show`, + desc: null, + category: null, + }; +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('EpgGuideProgramsService', () => { + const channels = signal([]); + const loadPrograms = jest.fn>, [unknown]>(); + const loadCoverage = jest.fn>, [unknown]>(); + let service: EpgGuideProgramsService; + + beforeEach(() => { + loadPrograms.mockReset(); + loadCoverage.mockReset(); + loadCoverage.mockResolvedValue(new Set()); + channels.set([channel('a'), channel('b'), channel('c', null)]); + const source: EpgGuideSource = { + channels, + scopes: signal([]), + scopeId: signal('all'), + setScope: jest.fn(), + loadPrograms, + loadCoverage, + activeChannelId: signal(null), + activate: jest.fn(), + }; + TestBed.configureTestingModule({ + providers: [ + EpgGuideProgramsService, + { provide: EPG_GUIDE_SOURCE, useValue: source }, + ], + }); + service = TestBed.inject(EpgGuideProgramsService); + }); + + it('reports "none" for channels without an EPG key and never requests them', async () => { + loadPrograms.mockResolvedValue(new Map()); + service.setWindow(1_000, 2_000); + service.ensureLoaded(channels()); + await flush(); + expect(service.statusFor('c')).toBe('none'); + const requested = loadPrograms.mock.calls[0][0] as { channels: EpgGuideChannel[] }; + expect(requested.channels.map((item) => item.id)).toEqual(['a', 'b']); + }); + + it('loads visible rows once, in chunks, and exposes their programmes', async () => { + const many = Array.from({ length: EPG_GUIDE_LOAD_CHUNK + 5 }, (_, i) => + channel(`ch-${i}`) + ); + channels.set(many); + loadPrograms.mockImplementation(async (window) => { + const { channels: requested } = window as { channels: EpgGuideChannel[] }; + return new Map(requested.map((item) => [item.id, [programFor(item.id)]])); + }); + service.setWindow(1_000, 2_000); + service.ensureLoaded(many); + expect(service.statusFor('ch-0')).toBe('loading'); + await flush(); + expect(loadPrograms).toHaveBeenCalledTimes(2); + expect(service.statusFor('ch-0')).toBe('loaded'); + expect(service.programsFor('ch-0')[0].title).toBe('ch-0 show'); + service.ensureLoaded(many); + expect(loadPrograms).toHaveBeenCalledTimes(2); + }); + + it('drops responses that belong to a previous window', async () => { + let resolveFirst!: (value: Map) => void; + loadPrograms.mockImplementationOnce( + () => new Promise((resolve) => (resolveFirst = resolve)) + ); + loadPrograms.mockResolvedValueOnce(new Map()); + service.setWindow(1_000, 2_000); + service.ensureLoaded(channels()); + service.setWindow(3_000, 4_000); + service.ensureLoaded(channels()); + resolveFirst(new Map([['a', [programFor('a')]]])); + await flush(); + expect(service.programsFor('a')).toEqual([]); + }); + + it('marks a failed batch as loaded-empty instead of retrying on every scroll', async () => { + loadPrograms.mockRejectedValue(new Error('ipc down')); + service.setWindow(1_000, 2_000); + service.ensureLoaded(channels()); + await flush(); + expect(service.statusFor('a')).toBe('loaded'); + service.ensureLoaded(channels()); + expect(loadPrograms).toHaveBeenCalledTimes(1); + }); + + it('loads coverage for the whole scope when the window is set and answers isCovered', async () => { + loadCoverage.mockResolvedValue(new Set(['a'])); + expect(service.coverageLoaded()).toBe(false); + expect(service.isCovered('b')).toBe(true); + service.setWindow(1_000, 2_000); + await flush(); + expect(loadCoverage).toHaveBeenCalledTimes(1); + expect(service.coverageLoaded()).toBe(true); + expect(service.isCovered('a')).toBe(true); + expect(service.isCovered('b')).toBe(false); + expect(service.isCovered('c')).toBe(false); + }); + + it('resets programmes and coverage when the scope channels change', async () => { + loadPrograms.mockResolvedValue(new Map([['a', [programFor('a')]]])); + service.setWindow(1_000, 2_000); + service.ensureLoaded(channels()); + await flush(); + expect(service.programsFor('a')).toHaveLength(1); + channels.set([channel('a'), channel('z')]); + TestBed.flushEffects(); + await flush(); + expect(service.statusFor('a')).toBe('idle'); + expect(loadCoverage).toHaveBeenCalledTimes(2); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts new file mode 100644 index 000000000..7b3253630 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts @@ -0,0 +1,170 @@ +import { computed, effect, inject, Injectable, signal } from '@angular/core'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + EPG_GUIDE_SOURCE, + EpgGuideChannel, + EpgGuideWindow, +} from './epg-guide-source'; + +/** Channels per `loadPrograms` call (the programme IPC caps at 100 keys). */ +export const EPG_GUIDE_LOAD_CHUNK = 100; +/** Channels per `loadCoverage` call (the coverage IPC caps at 2000 keys). */ +export const EPG_GUIDE_COVERAGE_CHUNK = 1000; + +export type EpgGuideRowStatus = 'idle' | 'loading' | 'loaded' | 'none'; + +const EMPTY: EpgProgram[] = []; + +/** + * Per-day programme cache for the guide. Programmes load lazily for the rows + * the viewport asks about; coverage (which rows have anything at all) loads + * eagerly for the whole scope so "Only with EPG" can answer before rows + * scroll into view. Every response is tagged with the generation it was + * requested under, so a window or scope change makes older answers no-ops. + */ +@Injectable() +export class EpgGuideProgramsService { + private readonly source = inject(EPG_GUIDE_SOURCE); + + private readonly programs = signal>( + new Map() + ); + private readonly statuses = signal>( + new Map() + ); + private readonly coverage = signal | null>(null); + + private window: { fromMs: number; toMs: number } | null = null; + private generation = 0; + + readonly coverageLoaded = computed(() => this.coverage() !== null); + + constructor() { + // A new channel set (scope change) invalidates everything, including + // coverage: the toggle must be answered for the new rows. + effect(() => { + this.source.channels(); + this.invalidate(); + this.requestCoverage(); + }); + } + + setWindow(fromMs: number, toMs: number): void { + if (this.window?.fromMs === fromMs && this.window?.toMs === toMs) { + return; + } + this.window = { fromMs, toMs }; + this.invalidate(); + this.requestCoverage(); + } + + programsFor(channelId: string): EpgProgram[] { + return this.programs().get(channelId) ?? EMPTY; + } + + statusFor(channelId: string): EpgGuideRowStatus { + return this.statuses().get(channelId) ?? 'idle'; + } + + /** Unknown (coverage not loaded yet) counts as covered so rows never blink. */ + isCovered(channelId: string): boolean { + const coverage = this.coverage(); + return coverage === null || coverage.has(channelId); + } + + /** Request programmes for rows that are idle; chunked, generation-tagged. */ + ensureLoaded(channels: readonly EpgGuideChannel[]): void { + const window = this.window; + if (!window) { + return; + } + const pending = channels.filter( + (channel) => this.statusFor(channel.id) === 'idle' && channel.epgKey + ); + if (pending.length === 0) { + return; + } + this.patchStatuses(pending.map((channel) => [channel.id, 'loading'])); + const generation = this.generation; + for (let start = 0; start < pending.length; start += EPG_GUIDE_LOAD_CHUNK) { + const chunk = pending.slice(start, start + EPG_GUIDE_LOAD_CHUNK); + this.loadChunk({ channels: chunk, ...window }, generation); + } + } + + private loadChunk(window: EpgGuideWindow, generation: number): void { + this.source + .loadPrograms(window) + .then( + (result) => result, + () => new Map() + ) + .then((result) => { + if (generation !== this.generation) { + return; + } + const next = new Map(this.programs()); + for (const channel of window.channels) { + next.set(channel.id, result.get(channel.id) ?? EMPTY); + } + this.programs.set(next); + this.patchStatuses( + window.channels.map((channel) => [channel.id, 'loaded']) + ); + }); + } + + private requestCoverage(): void { + const window = this.window; + if (!window) { + return; + } + const generation = this.generation; + const keyed = this.source + .channels() + .filter((channel) => channel.epgKey !== null); + const chunks: EpgGuideChannel[][] = []; + for (let start = 0; start < keyed.length; start += EPG_GUIDE_COVERAGE_CHUNK) { + chunks.push(keyed.slice(start, start + EPG_GUIDE_COVERAGE_CHUNK)); + } + Promise.all( + chunks.map((channels) => + this.source + .loadCoverage({ channels, ...window }) + .then( + (covered) => covered, + () => new Set() + ) + ) + ).then((sets) => { + if (generation !== this.generation) { + return; + } + const merged = new Set(); + sets.forEach((set) => set.forEach((id) => merged.add(id))); + this.coverage.set(merged); + }); + } + + private invalidate(): void { + this.generation += 1; + this.programs.set(new Map()); + this.statuses.set( + new Map( + this.source + .channels() + .filter((channel) => channel.epgKey === null) + .map((channel) => [channel.id, 'none' as const]) + ) + ); + this.coverage.set(null); + } + + private patchStatuses(entries: Array<[string, EpgGuideRowStatus]>): void { + const next = new Map(this.statuses()); + for (const [id, status] of entries) { + next.set(id, status); + } + this.statuses.set(next); + } +}