mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(epg): add window-scoped guide programme queries
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
2bac120eff
commit
4300cb0c46
2 files changed
+520
No files matched your search
@@ -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<unknown>()): 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');
|
||||
});
|
||||
});
|
||||
@@ -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<EpgQueryService, 'getChannelMetadata'>;
|
||||
|
||||
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<Record<string, EpgProgram[]>> {
|
||||
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<string[]> {
|
||||
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<string, EpgProgram[]> {
|
||||
const result: Record<string, EpgProgram[]> = {};
|
||||
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<Map<string, string>> {
|
||||
const metadata = await this.resolver.getChannelMetadata(
|
||||
window.channelIds,
|
||||
window.sourceUrls.length > 0
|
||||
? { sourceUrls: window.sourceUrls }
|
||||
: {}
|
||||
);
|
||||
const resolved = new Map<string, string>();
|
||||
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<string, EpgProgram[]> {
|
||||
const grouped = new Map<string, EpgProgram[]>();
|
||||
const seen = new Set<string>();
|
||||
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();
|
||||
Reference in new issue
Block a user