mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
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:
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])
|
||||
)
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user