From 4300cb0c465cfdc8829a8ed8c2e786dbcf702e0b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 11:05:12 +0200 Subject: [PATCH] feat(epg): add window-scoped guide programme queries Co-Authored-By: Claude Fable 5.1 --- .../events/epg-guide-query.service.spec.ts | 262 ++++++++++++++++++ .../src/app/events/epg-guide-query.service.ts | 258 +++++++++++++++++ 2 files changed, 520 insertions(+) create mode 100644 apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts create mode 100644 apps/electron-backend/src/app/events/epg-guide-query.service.ts diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts new file mode 100644 index 000000000..a90fd65e6 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts @@ -0,0 +1,262 @@ +import { + EPG_GUIDE_MAX_CHANNELS_PER_REQUEST, + EpgGuideQueryService, + normalizeGuideWindow, +} from './epg-guide-query.service'; + +const getDatabase = jest.fn(); + +jest.mock('../database/connection', () => ({ + getDatabase: (...args: unknown[]) => getDatabase(...args), +})); + +jest.mock('../util/epg-logger', () => ({ + epgLogger: { error: jest.fn(), warn: jest.fn(), log: jest.fn() }, +})); + +function flattenSql(value: unknown, seen = new Set()): string { + if ( + value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return String(value ?? ''); + } + if (seen.has(value)) { + return ''; + } + seen.add(value); + if (Array.isArray(value)) { + return value.map((item) => flattenSql(item, seen)).join(' '); + } + const sqlLike = value as { + name?: unknown; + queryChunks?: unknown[]; + value?: unknown; + }; + if (Array.isArray(sqlLike.queryChunks)) { + return sqlLike.queryChunks + .map((chunk) => flattenSql(chunk, seen)) + .join(' '); + } + if (Array.isArray(sqlLike.value)) { + return sqlLike.value.join(' '); + } + if (typeof sqlLike.value === 'string') { + return sqlLike.value; + } + if (typeof sqlLike.name === 'string') { + return sqlLike.name; + } + return ''; +} + +function programRow( + channelId: string, + start: string, + stop: string, + title: string +) { + return { + channelId, + start, + stop, + title, + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + }; +} + +/** `db.select(cols).from().where().orderBy()` resolving to `rows`. */ +function programSelect(rows: unknown[], whereCalls: unknown[]) { + return jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn((condition: unknown) => { + whereCalls.push(condition); + return { orderBy: jest.fn().mockResolvedValue(rows) }; + }), + })), + })); +} + +/** `db.selectDistinct(cols).from().where()` resolving to `rows`. */ +function coverageSelect(rows: unknown[], whereCalls: unknown[]) { + return jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn((condition: unknown) => { + whereCalls.push(condition); + return Promise.resolve(rows); + }), + })), + })); +} + +const FROM = Date.UTC(2026, 8, 6, 0, 0, 0); +const TO = Date.UTC(2026, 8, 7, 0, 0, 0); + +describe('normalizeGuideWindow', () => { + it('rejects an empty or inverted window', () => { + expect( + normalizeGuideWindow({ channelIds: ['a'], fromMs: TO, toMs: FROM }) + ).toBeNull(); + expect( + normalizeGuideWindow({ channelIds: [' '], fromMs: FROM, toMs: TO }) + ).toBeNull(); + expect( + normalizeGuideWindow({ + channelIds: ['a'], + fromMs: Number.NaN, + toMs: TO, + }) + ).toBeNull(); + }); + + it('trims, de-duplicates and caps the channel keys', () => { + const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`); + const window = normalizeGuideWindow({ + channelIds: [' a ', 'a', ...ids], + fromMs: FROM, + toMs: TO, + }); + expect(window?.channelIds[0]).toBe('a'); + expect(window?.channelIds).toHaveLength( + EPG_GUIDE_MAX_CHANNELS_PER_REQUEST + ); + expect(window?.fromIso).toBe('2026-09-06T00:00:00.000Z'); + expect(window?.toIso).toBe('2026-09-07T00:00:00.000Z'); + }); +}); + +describe('EpgGuideQueryService', () => { + const getChannelMetadata = jest.fn(); + let service: EpgGuideQueryService; + + beforeEach(() => { + getDatabase.mockReset(); + getChannelMetadata.mockReset(); + service = new EpgGuideQueryService({ getChannelMetadata }, '[Test]'); + }); + + it('returns an empty list per requested key for an invalid window', async () => { + const result = await service.getProgramsForChannels({ + channelIds: ['a', 'b'], + fromMs: TO, + toMs: FROM, + }); + expect(result).toEqual({ a: [], b: [] }); + expect(getChannelMetadata).not.toHaveBeenCalled(); + }); + + it('resolves keys through channel metadata and maps rows back onto every requested key', async () => { + getChannelMetadata.mockResolvedValue({ + 'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null }, + 'zdf.de': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null }, + unknown: null, + }); + const whereCalls: unknown[] = []; + getDatabase.mockResolvedValue({ + select: programSelect( + [ + programRow( + 'zdf.de', + '2026-09-06T16:00:00.000Z', + '2026-09-06T16:45:00.000Z', + 'heute-journal' + ), + programRow( + 'zdf.de', + '2026-09-06T16:00:00.000Z', + '2026-09-06T16:45:00.000Z', + 'heute-journal' + ), + ], + whereCalls + ), + }); + + const result = await service.getProgramsForChannels({ + channelIds: ['ZDF HD', 'zdf.de', 'unknown'], + fromMs: FROM, + toMs: TO, + }); + + expect(getChannelMetadata).toHaveBeenCalledWith( + ['ZDF HD', 'zdf.de', 'unknown'], + {} + ); + expect(result['ZDF HD']).toHaveLength(1); + expect(result['zdf.de']).toHaveLength(1); + expect(result['ZDF HD'][0]).toMatchObject({ + channel: 'zdf.de', + title: 'heute-journal', + }); + expect(result['unknown']).toEqual([]); + const condition = flattenSql(whereCalls[0]); + expect(condition).toContain('channel_id'); + expect(condition).toContain('2026-09-07T00:00:00.000Z'); + expect(condition).toContain('2026-09-06T00:00:00.000Z'); + }); + + it('scopes the programme rows to the requested source URLs', async () => { + getChannelMetadata.mockResolvedValue({ + a: { id: 'a', displayName: 'A', iconUrl: null }, + }); + const whereCalls: unknown[] = []; + getDatabase.mockResolvedValue({ + select: programSelect([], whereCalls), + }); + + await service.getProgramsForChannels({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + sourceUrls: ['https://guide.example.com/epg.xml'], + }); + + expect(getChannelMetadata).toHaveBeenCalledWith(['a'], { + sourceUrls: ['https://guide.example.com/epg.xml'], + }); + expect(flattenSql(whereCalls[0])).toContain('source_url'); + }); + + it('fails soft when the database throws', async () => { + getChannelMetadata.mockResolvedValue({ + a: { id: 'a', displayName: 'A', iconUrl: null }, + }); + getDatabase.mockRejectedValue(new Error('locked')); + + await expect( + service.getProgramsForChannels({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + }) + ).resolves.toEqual({ a: [] }); + }); + + it('reports coverage for the requested keys whose channel has a programme in the window', async () => { + getChannelMetadata.mockResolvedValue({ + 'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null }, + 'ARTE': { id: 'arte.de', displayName: 'ARTE', iconUrl: null }, + none: null, + }); + const whereCalls: unknown[] = []; + getDatabase.mockResolvedValue({ + selectDistinct: coverageSelect([{ channelId: 'zdf.de' }], whereCalls), + }); + + const covered = await service.getProgramCoverage({ + channelIds: ['ZDF HD', 'ARTE', 'none'], + fromMs: FROM, + toMs: TO, + }); + + expect(covered).toEqual(['ZDF HD']); + expect(flattenSql(whereCalls[0])).toContain('channel_id'); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.ts new file mode 100644 index 000000000..9aba14dc6 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.ts @@ -0,0 +1,258 @@ +import { and, inArray, sql, type SQL } from 'drizzle-orm'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { getDatabase } from '../database/connection'; +import * as schema from '../database/schema'; +import { epgLogger } from '../util/epg-logger'; +import { epgQueryService, EpgQueryService } from './epg-query.service'; + +/** The renderer splits larger batches; anything beyond this is dropped. */ +export const EPG_GUIDE_MAX_CHANNELS_PER_REQUEST = 100; + +/** Largest |ms| `Date` can serialize; a corrupt payload must not throw in SQL. */ +const MAX_SERIALIZABLE_MS = 8.64e15; + +export interface EpgGuideWindowRequest { + channelIds: string[]; + /** Provider-clock instants (the renderer removes the display offset). */ + fromMs: number; + toMs: number; + sourceUrls?: string[]; +} + +export interface NormalizedGuideWindow { + channelIds: string[]; + fromIso: string; + toIso: string; + sourceUrls: string[]; +} + +interface GuideProgramRow { + channelId: string; + start: string; + stop: string; + title: string; + description: string | null; + category: string | null; + iconUrl: string | null; + rating: string | null; + episodeNum: string | null; +} + +type ChannelResolver = Pick; + +function isUsableInstant(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + Math.abs(value) <= MAX_SERIALIZABLE_MS + ); +} + +/** Validates and trims a request; `null` means "nothing to query". */ +export function normalizeGuideWindow( + request: EpgGuideWindowRequest +): NormalizedGuideWindow | null { + if ( + !isUsableInstant(request.fromMs) || + !isUsableInstant(request.toMs) || + request.fromMs >= request.toMs || + !Array.isArray(request.channelIds) + ) { + return null; + } + const channelIds = Array.from( + new Set( + request.channelIds + .map((id) => (typeof id === 'string' ? id.trim() : '')) + .filter((id) => id.length > 0) + ) + ).slice(0, EPG_GUIDE_MAX_CHANNELS_PER_REQUEST); + if (channelIds.length === 0) { + return null; + } + const sourceUrls = Array.from( + new Set( + (request.sourceUrls ?? []) + .map((url) => url.trim()) + .filter((url) => url.length > 0) + ) + ); + return { + channelIds, + fromIso: new Date(request.fromMs).toISOString(), + toIso: new Date(request.toMs).toISOString(), + sourceUrls, + }; +} + +/** + * Programme-guide reads for a batch of playlist channel keys: every programme + * overlapping a time window, and which keys have any programme in it at all. + * Keys resolve to XMLTV channel ids through the same metadata lookup the + * sidebar uses (exact id, case-insensitive id, display name); manual mappings + * are applied by the IPC layer before the keys reach this service. + */ +export class EpgGuideQueryService { + constructor( + private readonly resolver: ChannelResolver = epgQueryService, + private readonly loggerLabel = '[EPG Guide]' + ) {} + + async getProgramsForChannels( + request: EpgGuideWindowRequest + ): Promise> { + const result = this.emptyResult(request.channelIds); + const window = normalizeGuideWindow(request); + if (!window) { + return result; + } + try { + const resolved = await this.resolveChannelIds(window); + const epgIds = Array.from(new Set(resolved.values())); + if (epgIds.length === 0) { + return result; + } + const db = await getDatabase(); + const rows: GuideProgramRow[] = await db + .select({ + channelId: schema.epgPrograms.channelId, + start: schema.epgPrograms.start, + stop: schema.epgPrograms.stop, + title: schema.epgPrograms.title, + description: schema.epgPrograms.description, + category: schema.epgPrograms.category, + iconUrl: schema.epgPrograms.iconUrl, + rating: schema.epgPrograms.rating, + episodeNum: schema.epgPrograms.episodeNum, + }) + .from(schema.epgPrograms) + .where(this.windowCondition(epgIds, window)) + .orderBy(schema.epgPrograms.start); + const byEpgId = this.groupPrograms(rows); + for (const [requestedId, epgId] of resolved) { + result[requestedId] = byEpgId.get(epgId) ?? []; + } + } catch (error) { + epgLogger.error( + this.loggerLabel, + 'Error loading guide programmes:', + error + ); + } + return result; + } + + async getProgramCoverage( + request: EpgGuideWindowRequest + ): Promise { + const window = normalizeGuideWindow(request); + if (!window) { + return []; + } + try { + const resolved = await this.resolveChannelIds(window); + const epgIds = Array.from(new Set(resolved.values())); + if (epgIds.length === 0) { + return []; + } + const db = await getDatabase(); + const rows: Array<{ channelId: string }> = await db + .selectDistinct({ channelId: schema.epgPrograms.channelId }) + .from(schema.epgPrograms) + .where(this.windowCondition(epgIds, window)); + const covered = new Set(rows.map((row) => row.channelId)); + return window.channelIds.filter((requestedId) => { + const epgId = resolved.get(requestedId); + return epgId !== undefined && covered.has(epgId); + }); + } catch (error) { + epgLogger.error( + this.loggerLabel, + 'Error loading guide coverage:', + error + ); + return []; + } + } + + private emptyResult(channelIds: string[]): Record { + const result: Record = {}; + for (const id of Array.isArray(channelIds) ? channelIds : []) { + if (typeof id === 'string' && id.trim().length > 0) { + result[id.trim()] = []; + } + } + return result; + } + + /** requested key → XMLTV channel id (keys without a match are absent). */ + private async resolveChannelIds( + window: NormalizedGuideWindow + ): Promise> { + const metadata = await this.resolver.getChannelMetadata( + window.channelIds, + window.sourceUrls.length > 0 + ? { sourceUrls: window.sourceUrls } + : {} + ); + const resolved = new Map(); + for (const requestedId of window.channelIds) { + const epgId = metadata[requestedId]?.id; + if (epgId) { + resolved.set(requestedId, epgId); + } + } + return resolved; + } + + /** + * Overlap test in SQLite `datetime()` so provider-local offsets in the + * stored ISO strings compare correctly against the UTC window bounds. + */ + private windowCondition( + epgIds: string[], + window: NormalizedGuideWindow + ): SQL { + const overlap = and( + inArray(schema.epgPrograms.channelId, epgIds), + sql`datetime(${schema.epgPrograms.start}) < datetime(${window.toIso})`, + sql`datetime(${schema.epgPrograms.stop}) > datetime(${window.fromIso})` + ) as SQL; + if (window.sourceUrls.length === 0) { + return overlap; + } + return and( + overlap, + inArray(schema.epgPrograms.sourceUrl, window.sourceUrls) + ) as SQL; + } + + /** Group by channel and collapse duplicate slots (same start + title). */ + private groupPrograms(rows: GuideProgramRow[]): Map { + const grouped = new Map(); + const seen = new Set(); + for (const row of rows) { + const key = `${row.channelId}|${row.start}|${row.title}`; + if (seen.has(key) || !row.start || !row.stop || !row.title) { + continue; + } + seen.add(key); + const list = grouped.get(row.channelId) ?? []; + list.push({ + start: row.start, + stop: row.stop, + channel: row.channelId, + title: row.title, + desc: row.description, + category: row.category, + iconUrl: row.iconUrl, + rating: row.rating, + episodeNum: row.episodeNum, + }); + grouped.set(row.channelId, list); + } + return grouped; + } +} + +export const epgGuideQueryService = new EpgGuideQueryService();