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 index 37eaa266c..aed83f7a3 100644 --- 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 @@ -59,6 +59,9 @@ describe('EpgGuideProgramsService', () => { ], }); service = TestBed.inject(EpgGuideProgramsService); + // Consume the constructor effect's first run (a no-op by design) so + // each test starts from a clean, predictable call count. + TestBed.flushEffects(); }); it('reports "none" for channels without an EPG key and never requests them', async () => { @@ -135,10 +138,56 @@ describe('EpgGuideProgramsService', () => { service.ensureLoaded(channels()); await flush(); expect(service.programsFor('a')).toHaveLength(1); + const callsBefore = loadCoverage.mock.calls.length; channels.set([channel('a'), channel('z')]); TestBed.flushEffects(); await flush(); expect(service.statusFor('a')).toBe('idle'); - expect(loadCoverage).toHaveBeenCalledTimes(2); + expect(loadCoverage.mock.calls.length).toBe(callsBefore + 1); + const requested = loadCoverage.mock.calls[callsBefore][0] as { + channels: EpgGuideChannel[]; + }; + const requestedIds = requested.channels.map((item) => item.id); + expect(requestedIds).toContain('z'); + expect(requestedIds).not.toContain('b'); + }); + + it('does not re-invalidate or drop an in-flight load when the effect flushes again with unchanged channels', async () => { + loadPrograms.mockResolvedValue(new Map([['a', [programFor('a')]]])); + service.setWindow(1_000, 2_000); + service.ensureLoaded(channels()); + TestBed.flushEffects(); + await flush(); + expect(loadCoverage).toHaveBeenCalledTimes(1); + expect(service.programsFor('a')).toHaveLength(1); + }); + + it('leaves coverage unknown when a coverage chunk rejects', async () => { + loadCoverage.mockRejectedValue(new Error('coverage down')); + service.setWindow(1_000, 2_000); + await flush(); + expect(service.coverageLoaded()).toBe(false); + expect(service.isCovered('b')).toBe(true); + }); + + it('treats an empty-string epgKey the same as null', async () => { + channels.set([channel('a'), channel('empty', '')]); + loadPrograms.mockResolvedValue(new Map()); + service.setWindow(1_000, 2_000); + service.ensureLoaded(channels()); + await flush(); + expect(service.statusFor('empty')).toBe('none'); + const requestedPrograms = loadPrograms.mock.calls[0][0] as { + channels: EpgGuideChannel[]; + }; + expect(requestedPrograms.channels.map((item) => item.id)).not.toContain( + 'empty' + ); + const requestedCoverage = loadCoverage.mock.calls[0][0] as { + channels: EpgGuideChannel[]; + }; + expect(requestedCoverage.channels.map((item) => item.id)).not.toContain( + 'empty' + ); }); }); 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 index 7b3253630..4fe9e7335 100644 --- 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 @@ -1,5 +1,5 @@ import { computed, effect, inject, Injectable, signal } from '@angular/core'; -import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { createDevLogger, EpgProgram } from '@iptvnator/shared/interfaces'; import { EPG_GUIDE_SOURCE, EpgGuideChannel, @@ -8,12 +8,24 @@ import { /** 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). */ +/** + * Channels per `loadCoverage` call. Deliberately half the IPC's 2000-key cap: + * it leaves headroom for the host to append keys to the same request (e.g. a + * scope change mid-flight) without immediately needing a second chunk and a + * new round-trip contract. + */ export const EPG_GUIDE_COVERAGE_CHUNK = 1000; export type EpgGuideRowStatus = 'idle' | 'loading' | 'loaded' | 'none'; -const EMPTY: EpgProgram[] = []; +const EMPTY: readonly EpgProgram[] = []; + +const debugEpgGuidePrograms = createDevLogger('EpgGuideProgramsService'); + +/** A channel is only ever requested when it carries a real, non-blank key. */ +function hasEpgKey(channel: EpgGuideChannel): boolean { + return typeof channel.epgKey === 'string' && channel.epgKey.trim().length > 0; +} /** * Per-day programme cache for the guide. Programmes load lazily for the rows @@ -26,7 +38,7 @@ const EMPTY: EpgProgram[] = []; export class EpgGuideProgramsService { private readonly source = inject(EPG_GUIDE_SOURCE); - private readonly programs = signal>( + private readonly programs = signal>( new Map() ); private readonly statuses = signal>( @@ -34,31 +46,45 @@ export class EpgGuideProgramsService { ); private readonly coverage = signal | null>(null); - private window: { fromMs: number; toMs: number } | null = null; + private range: { fromMs: number; toMs: number } | null = null; private generation = 0; + /** Last channel reference seen by the constructor effect (see below). */ + private seenChannels: readonly EpgGuideChannel[] | null = null; 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. + // coverage: the toggle must be answered for the new rows. The first + // effect run must NOT invalidate: a host can call `setWindow` (and + // thus `ensureLoaded`) before the initial change-detection flush, and + // invalidating here would bump the generation and drop that + // in-flight request's answer along with any coverage it kicked off. effect(() => { - this.source.channels(); + const next = this.source.channels(); + if (next === this.seenChannels) { + return; + } + const first = this.seenChannels === null; + this.seenChannels = next; + if (first) { + return; + } this.invalidate(); this.requestCoverage(); }); } setWindow(fromMs: number, toMs: number): void { - if (this.window?.fromMs === fromMs && this.window?.toMs === toMs) { + if (this.range?.fromMs === fromMs && this.range?.toMs === toMs) { return; } - this.window = { fromMs, toMs }; + this.range = { fromMs, toMs }; this.invalidate(); this.requestCoverage(); } - programsFor(channelId: string): EpgProgram[] { + programsFor(channelId: string): readonly EpgProgram[] { return this.programs().get(channelId) ?? EMPTY; } @@ -66,7 +92,8 @@ export class EpgGuideProgramsService { return this.statuses().get(channelId) ?? 'idle'; } - /** Unknown (coverage not loaded yet) counts as covered so rows never blink. */ + /** Unknown (coverage not loaded yet, or a chunk failed) counts as covered + * so rows never blink. */ isCovered(channelId: string): boolean { const coverage = this.coverage(); return coverage === null || coverage.has(channelId); @@ -74,12 +101,12 @@ export class EpgGuideProgramsService { /** Request programmes for rows that are idle; chunked, generation-tagged. */ ensureLoaded(channels: readonly EpgGuideChannel[]): void { - const window = this.window; - if (!window) { + const range = this.range; + if (!range) { return; } const pending = channels.filter( - (channel) => this.statusFor(channel.id) === 'idle' && channel.epgKey + (channel) => this.statusFor(channel.id) === 'idle' && hasEpgKey(channel) ); if (pending.length === 0) { return; @@ -88,60 +115,77 @@ export class EpgGuideProgramsService { 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); + this.loadChunk({ channels: chunk, ...range }, generation); } } - private loadChunk(window: EpgGuideWindow, generation: number): void { + private loadChunk(request: EpgGuideWindow, generation: number): void { this.source - .loadPrograms(window) + .loadPrograms(request) .then( (result) => result, - () => new Map() + () => { + debugEpgGuidePrograms( + 'loadPrograms failed for a chunk; marking as loaded-empty', + { channelCount: request.channels.length } + ); + return null; + } ) .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); + for (const channel of request.channels) { + next.set(channel.id, result?.get(channel.id) ?? EMPTY); } this.programs.set(next); this.patchStatuses( - window.channels.map((channel) => [channel.id, 'loaded']) + request.channels.map((channel) => [channel.id, 'loaded']) ); }); } private requestCoverage(): void { - const window = this.window; - if (!window) { + const range = this.range; + if (!range) { return; } const generation = this.generation; - const keyed = this.source - .channels() - .filter((channel) => channel.epgKey !== null); + const keyed = this.source.channels().filter(hasEpgKey); 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() - ) + this.source.loadCoverage({ channels, ...range }).then( + (covered) => covered, + () => null + ) ) ).then((sets) => { if (generation !== this.generation) { return; } + // Fail open: if any chunk failed, coverage stays unknown (`null`) + // rather than publishing a partial/empty set that would hide rows + // that do have programmes. + if (sets.some((set) => set === null)) { + debugEpgGuidePrograms( + 'loadCoverage failed for a chunk; leaving coverage unknown', + { chunkCount: sets.length } + ); + return; + } const merged = new Set(); - sets.forEach((set) => set.forEach((id) => merged.add(id))); + for (const set of sets) { + if (set === null) { + continue; + } + set.forEach((id) => merged.add(id)); + } this.coverage.set(merged); }); } @@ -153,7 +197,7 @@ export class EpgGuideProgramsService { new Map( this.source .channels() - .filter((channel) => channel.epgKey === null) + .filter((channel) => !hasEpgKey(channel)) .map((channel) => [channel.id, 'none' as const]) ) );