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