feat(epg): cache guide programmes per day with batched loading

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 12:32:23 +02:00
1 parent 19aee624f7
commit 9f33706404
2 files changed
+314

No files matched your search

@@ -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<void> {
await Promise.resolve();
await Promise.resolve();
}
describe('EpgGuideProgramsService', () => {
const channels = signal<EpgGuideChannel[]>([]);
const loadPrograms = jest.fn<Promise<Map<string, EpgProgram[]>>, [unknown]>();
const loadCoverage = jest.fn<Promise<Set<string>>, [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<string, EpgProgram[]>) => 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);
});
});
@@ -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<ReadonlyMap<string, EpgProgram[]>>(
new Map()
);
private readonly statuses = signal<ReadonlyMap<string, EpgGuideRowStatus>>(
new Map()
);
private readonly coverage = signal<ReadonlySet<string> | 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<string, EpgProgram[]>()
)
.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<string>()
)
)
).then((sets) => {
if (generation !== this.generation) {
return;
}
const merged = new Set<string>();
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);
}
}