fix(epg): make guide programme cache robust to first-run effects and coverage failures

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 12:40:12 +02:00
1 parent 36be2cbb58
commit 5f86631786
2 files changed
+128 -35

No files matched your search

@@ -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'
);
});
});
@@ -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<ReadonlyMap<string, EpgProgram[]>>(
private readonly programs = signal<ReadonlyMap<string, readonly EpgProgram[]>>(
new Map()
);
private readonly statuses = signal<ReadonlyMap<string, EpgGuideRowStatus>>(
@@ -34,31 +46,45 @@ export class EpgGuideProgramsService {
);
private readonly coverage = signal<ReadonlySet<string> | 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<string, EpgProgram[]>()
() => {
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<string>()
)
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<string>();
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])
)
);