fix(epg): harden guide query scoping, caps and row mapping

- Scoped guide programme/coverage queries now include legacy
  (unsourced) rows via source_url IN (...) OR IS NULL OR '',
  mirroring EpgQueryService's legacy fallback.
- getProgramsForChannels/getProgramCoverage build their result from
  the normalized, capped window.channelIds instead of the raw
  request, so a key cut by the cap is absent rather than [] — an
  invalid window now returns {}. Truncation logs counts only.
- Split the 100-channel guide cap from a new 2000-key coverage cap,
  and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a
  parameter and moved (with guideWindowOverlapSqlText) into
  epg-guide-window.util.ts.
- Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram)
  into epg-program-row.util.ts, used by both EpgQueryService and
  EpgGuideQueryService so invalid start/stop rows are dropped
  identically in both.
- Added a real-SQLite-backed test for the overlap predicate's exact
  text, plus per-key array copies in the response.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4gray committed 2026-09-06 11:23:24 +02:00
1 parent 4300cb0c46
commit ddf811c6de
5 files changed
+480 -216

No files matched your search

@@ -1,8 +1,13 @@
import { execFileSync } from 'node:child_process';
import {
EPG_GUIDE_MAX_CHANNELS_PER_REQUEST,
EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST,
EpgGuideQueryService,
guideWindowCondition,
guideWindowOverlapSqlText,
normalizeGuideWindow,
} from './epg-guide-query.service';
import { epgLogger } from '../util/epg-logger';
const getDatabase = jest.fn();
@@ -116,7 +121,7 @@ describe('normalizeGuideWindow', () => {
).toBeNull();
});
it('trims, de-duplicates and caps the channel keys', () => {
it('trims, de-duplicates and caps the channel keys to the default limit', () => {
const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`);
const window = normalizeGuideWindow({
channelIds: [' a ', 'a', ...ids],
@@ -130,6 +135,113 @@ describe('normalizeGuideWindow', () => {
expect(window?.fromIso).toBe('2026-09-06T00:00:00.000Z');
expect(window?.toIso).toBe('2026-09-07T00:00:00.000Z');
});
it('accepts an explicit larger cap for coverage-sized requests', () => {
const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`);
const window = normalizeGuideWindow(
{ channelIds: ids, fromMs: FROM, toMs: TO },
EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST
);
expect(window?.channelIds).toHaveLength(150);
});
it('caps sourceUrls at 50 regardless of the channel cap', () => {
const urls = Array.from(
{ length: 60 },
(_, index) => `https://epg.example.com/${index}.xml`
);
const window = normalizeGuideWindow({
channelIds: ['a'],
fromMs: FROM,
toMs: TO,
sourceUrls: urls,
});
expect(window?.sourceUrls).toHaveLength(50);
});
});
describe('guideWindowCondition', () => {
it('flattens to the same "< datetime( ... ) AND > datetime( ... )" operator sequence as the plain-SQL twin', () => {
const window = normalizeGuideWindow({
channelIds: ['zdf.de'],
fromMs: FROM,
toMs: TO,
});
if (!window) {
throw new Error('expected a valid window');
}
const flattened = flattenSql(guideWindowCondition(['zdf.de'], window));
const ltIndex = flattened.indexOf('< datetime(');
const gtIndex = flattened.indexOf('> datetime(');
expect(ltIndex).toBeGreaterThan(-1);
expect(gtIndex).toBeGreaterThan(ltIndex);
});
});
/** `true` when the CLI answers `sqlite3 -version`. Present on macOS and CI. */
function hasSqlite(): boolean {
try {
execFileSync('sqlite3', ['-version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function quote(literal: string): string {
return `'${literal.replace(/'/g, "''")}'`;
}
const describeWithSqlite = hasSqlite() ? describe : describe.skip;
describeWithSqlite('guideWindowOverlapSqlText against SQLite', () => {
const FROM_ISO = '2026-09-06T00:00:00.000Z';
const TO_ISO = '2026-09-07T00:00:00.000Z';
function overlappingChannelIds(
rows: Array<[channelId: string, start: string, stop: string]>
): string[] {
const predicate = guideWindowOverlapSqlText(FROM_ISO, TO_ISO);
const values = rows
.map(
([channelId, start, stop]) =>
`(${quote(channelId)}, ${quote(start)}, ${quote(stop)})`
)
.join(', ');
const script = [
'CREATE TABLE epg_programs (channel_id TEXT, start TEXT, stop TEXT);',
`INSERT INTO epg_programs (channel_id, start, stop) VALUES ${values};`,
`SELECT channel_id FROM epg_programs WHERE ${predicate} ORDER BY channel_id;`,
].join('\n');
const out = execFileSync('sqlite3', [':memory:', script], {
encoding: 'utf8',
});
return out
.trim()
.split('\n')
.filter((line) => line.length > 0);
}
it('keeps rows overlapping the window and drops boundary-touching or non-overlapping rows', () => {
const result = overlappingChannelIds([
// Crosses the window start: overlaps.
['a', '2026-09-05T23:30:00.000Z', '2026-09-06T00:30:00.000Z'],
// Spans the whole window: overlaps.
['b', '2026-09-01T00:00:00.000Z', '2026-09-10T00:00:00.000Z'],
// stop === from: excluded (strict `>`).
['c', '2026-09-05T22:00:00.000Z', '2026-09-06T00:00:00.000Z'],
// start === to: excluded (strict `<`).
['d', '2026-09-07T00:00:00.000Z', '2026-09-07T01:00:00.000Z'],
// Stored with a +03:00 offset. In UTC this is
// [2026-09-06T23:00:00Z, 2026-09-07T00:30:00Z), which overlaps —
// but a raw string compare of "07T02:00...+03:00" against
// "07T00:00...Z" would say `start < to` is FALSE (lexically "02"
// sorts after "00"), wrongly excluding it. Only `datetime()`
// normalization recovers the correct overlap.
['e', '2026-09-07T02:00:00+03:00', '2026-09-07T03:30:00+03:00'],
]);
expect(result).toEqual(['a', 'b', 'e']);
});
});
describe('EpgGuideQueryService', () => {
@@ -139,16 +251,18 @@ describe('EpgGuideQueryService', () => {
beforeEach(() => {
getDatabase.mockReset();
getChannelMetadata.mockReset();
(epgLogger.log as jest.Mock).mockReset();
(epgLogger.error as jest.Mock).mockReset();
service = new EpgGuideQueryService({ getChannelMetadata }, '[Test]');
});
it('returns an empty list per requested key for an invalid window', async () => {
it('returns an empty object (no per-key placeholders) for an invalid window', async () => {
const result = await service.getProgramsForChannels({
channelIds: ['a', 'b'],
fromMs: TO,
toMs: FROM,
});
expect(result).toEqual({ a: [], b: [] });
expect(result).toEqual({});
expect(getChannelMetadata).not.toHaveBeenCalled();
});
@@ -195,6 +309,9 @@ describe('EpgGuideQueryService', () => {
channel: 'zdf.de',
title: 'heute-journal',
});
// Both keys resolved to the same channel — each must get its own
// array so mutating one can never affect the other.
expect(result['ZDF HD']).not.toBe(result['zdf.de']);
expect(result['unknown']).toEqual([]);
const condition = flattenSql(whereCalls[0]);
expect(condition).toContain('channel_id');
@@ -202,7 +319,7 @@ describe('EpgGuideQueryService', () => {
expect(condition).toContain('2026-09-06T00:00:00.000Z');
});
it('scopes the programme rows to the requested source URLs', async () => {
it('scopes the programme rows to the requested source URLs plus legacy (unsourced) rows', async () => {
getChannelMetadata.mockResolvedValue({
a: { id: 'a', displayName: 'A', iconUrl: null },
});
@@ -221,7 +338,9 @@ describe('EpgGuideQueryService', () => {
expect(getChannelMetadata).toHaveBeenCalledWith(['a'], {
sourceUrls: ['https://guide.example.com/epg.xml'],
});
expect(flattenSql(whereCalls[0])).toContain('source_url');
const condition = flattenSql(whereCalls[0]).toLowerCase();
expect(condition).toContain('source_url');
expect(condition).toContain('is null');
});
it('fails soft when the database throws', async () => {
@@ -239,15 +358,40 @@ describe('EpgGuideQueryService', () => {
).resolves.toEqual({ a: [] });
});
it('drops channel keys cut by the per-request cap from the result entirely', async () => {
const ids = Array.from({ length: 101 }, (_, index) => `ch-${index}`);
getChannelMetadata.mockResolvedValue({});
const result = await service.getProgramsForChannels({
channelIds: ids,
fromMs: FROM,
toMs: TO,
});
expect(Object.keys(result)).toHaveLength(
EPG_GUIDE_MAX_CHANNELS_PER_REQUEST
);
// The 101st key was cut by the cap: absent, never present as `[]`.
expect('ch-100' in result).toBe(false);
expect(epgLogger.log).toHaveBeenCalledWith(
'[Test]',
'Guide request truncated',
{ requested: 101, kept: EPG_GUIDE_MAX_CHANNELS_PER_REQUEST }
);
});
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 },
ARTE: { id: 'arte.de', displayName: 'ARTE', iconUrl: null },
none: null,
});
const whereCalls: unknown[] = [];
getDatabase.mockResolvedValue({
selectDistinct: coverageSelect([{ channelId: 'zdf.de' }], whereCalls),
selectDistinct: coverageSelect(
[{ channelId: 'zdf.de' }],
whereCalls
),
});
const covered = await service.getProgramCoverage({
@@ -259,4 +403,17 @@ describe('EpgGuideQueryService', () => {
expect(covered).toEqual(['ZDF HD']);
expect(flattenSql(whereCalls[0])).toContain('channel_id');
});
it('does not drop any of 150 requested keys under the larger coverage cap', async () => {
const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`);
getChannelMetadata.mockResolvedValue({});
await service.getProgramCoverage({
channelIds: ids,
fromMs: FROM,
toMs: TO,
});
expect(getChannelMetadata).toHaveBeenCalledWith(ids, {});
});
});
@@ -1,88 +1,63 @@
import { and, inArray, sql, type SQL } from 'drizzle-orm';
import { and, eq, inArray, isNull, or, 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 {
EpgProgramRow,
isValidEpgProgram,
toEpgProgramFromRow,
} from './epg-program-row.util';
import { epgQueryService, EpgQueryService } from './epg-query.service';
import {
EPG_GUIDE_MAX_CHANNELS_PER_REQUEST,
EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST,
EpgGuideWindowRequest,
NormalizedGuideWindow,
normalizeGuideWindow,
} from './epg-guide-window.util';
/** 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;
}
export {
EPG_GUIDE_MAX_CHANNELS_PER_REQUEST,
EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST,
guideWindowOverlapSqlText,
normalizeGuideWindow,
} from './epg-guide-window.util';
export type {
EpgGuideWindowRequest,
NormalizedGuideWindow,
} from './epg-guide-window.util';
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;
/**
* Overlap test in SQLite `datetime()` so provider-local offsets in the
* stored ISO strings compare correctly against the UTC window bounds. When
* `sourceUrls` is non-empty, the scope is additive — "these sources plus
* legacy (unsourced) rows" — mirroring `EpgQueryService`'s legacy fallback,
* so pre-multi-source imports (`source_url` null/empty) are never dropped
* just because a caller scoped the request to specific EPG sources.
*/
export function guideWindowCondition(
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;
}
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,
};
return and(
overlap,
or(
inArray(schema.epgPrograms.sourceUrl, window.sourceUrls),
isNull(schema.epgPrograms.sourceUrl),
eq(schema.epgPrograms.sourceUrl, '')
) as SQL
) as SQL;
}
/**
@@ -98,14 +73,25 @@ export class EpgGuideQueryService {
private readonly loggerLabel = '[EPG Guide]'
) {}
/**
* Response keys are exactly the trimmed, de-duplicated, cap-respecting
* requested keys (`window.channelIds`) — never the raw request. A key cut
* by the per-request cap is absent from the result, never present with an
* empty list, so a caller can tell "queried, nothing found" apart from
* "not queried at all". An invalid window returns `{}`.
*/
async getProgramsForChannels(
request: EpgGuideWindowRequest
): Promise<Record<string, EpgProgram[]>> {
const result = this.emptyResult(request.channelIds);
const window = normalizeGuideWindow(request);
const window = normalizeGuideWindow(
request,
EPG_GUIDE_MAX_CHANNELS_PER_REQUEST
);
if (!window) {
return result;
return {};
}
this.warnIfTruncated(request.channelIds, window.channelIds);
const result = this.emptyResult(window.channelIds);
try {
const resolved = await this.resolveChannelIds(window);
const epgIds = Array.from(new Set(resolved.values()));
@@ -113,7 +99,7 @@ export class EpgGuideQueryService {
return result;
}
const db = await getDatabase();
const rows: GuideProgramRow[] = await db
const rows: EpgProgramRow[] = await db
.select({
channelId: schema.epgPrograms.channelId,
start: schema.epgPrograms.start,
@@ -126,11 +112,13 @@ export class EpgGuideQueryService {
episodeNum: schema.epgPrograms.episodeNum,
})
.from(schema.epgPrograms)
.where(this.windowCondition(epgIds, window))
.where(guideWindowCondition(epgIds, window))
.orderBy(schema.epgPrograms.start);
const byEpgId = this.groupPrograms(rows);
for (const [requestedId, epgId] of resolved) {
result[requestedId] = byEpgId.get(epgId) ?? [];
// Copy so two requested keys resolving to the same channel
// never share one array reference.
result[requestedId] = [...(byEpgId.get(epgId) ?? [])];
}
} catch (error) {
epgLogger.error(
@@ -145,10 +133,14 @@ export class EpgGuideQueryService {
async getProgramCoverage(
request: EpgGuideWindowRequest
): Promise<string[]> {
const window = normalizeGuideWindow(request);
const window = normalizeGuideWindow(
request,
EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST
);
if (!window) {
return [];
}
this.warnIfTruncated(request.channelIds, window.channelIds);
try {
const resolved = await this.resolveChannelIds(window);
const epgIds = Array.from(new Set(resolved.values()));
@@ -159,7 +151,7 @@ export class EpgGuideQueryService {
const rows: Array<{ channelId: string }> = await db
.selectDistinct({ channelId: schema.epgPrograms.channelId })
.from(schema.epgPrograms)
.where(this.windowCondition(epgIds, window));
.where(guideWindowCondition(epgIds, window));
const covered = new Set(rows.map((row) => row.channelId));
return window.channelIds.filter((requestedId) => {
const epgId = resolved.get(requestedId);
@@ -177,14 +169,31 @@ export class EpgGuideQueryService {
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()] = [];
}
for (const id of channelIds) {
result[id] = [];
}
return result;
}
/**
* Logs (counts only — no channel keys or source URLs) when the per-request
* cap dropped part of the request, so an oversized batch is visible in
* diagnostics instead of silently losing channels.
*/
private warnIfTruncated(requestedIds: unknown, keptIds: string[]): void {
const requestedCount = new Set(
(Array.isArray(requestedIds) ? requestedIds : [])
.map((id) => (typeof id === 'string' ? id.trim() : ''))
.filter((id) => id.length > 0)
).size;
if (requestedCount > keptIds.length) {
epgLogger.log(this.loggerLabel, 'Guide request truncated', {
requested: requestedCount,
kept: keptIds.length,
});
}
}
/** requested key → XMLTV channel id (keys without a match are absent). */
private async resolveChannelIds(
window: NormalizedGuideWindow
@@ -206,49 +215,25 @@ export class EpgGuideQueryService {
}
/**
* Overlap test in SQLite `datetime()` so provider-local offsets in the
* stored ISO strings compare correctly against the UTC window bounds.
* Group by channel, drop rows with an invalid/unparsable start or stop
* (mirrors `EpgQueryService.isValidEpgProgram`), and collapse duplicate
* slots (same start + title).
*/
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[]> {
private groupPrograms(rows: EpgProgramRow[]): 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) {
if (seen.has(key) || !row.title) {
continue;
}
const program = toEpgProgramFromRow(row);
if (!isValidEpgProgram(program)) {
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,
});
list.push(program);
grouped.set(row.channelId, list);
}
return grouped;
@@ -0,0 +1,102 @@
/** The renderer splits larger channel batches; anything beyond this is dropped. */
export const EPG_GUIDE_MAX_CHANNELS_PER_REQUEST = 100;
/**
* Coverage checks are cheap (`selectDistinct` over an already-scoped id set)
* and portal hosts may probe many more keys than a single guide screen shows
* at once, so this cap is intentionally much larger than the programme cap.
*/
export const EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST = 2000;
/** Bound on how many distinct EPG source URLs one request may scope to. */
const MAX_SOURCE_URLS_PER_REQUEST = 50;
/** 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[];
}
function isUsableInstant(value: unknown): value is number {
return (
typeof value === 'number' &&
Number.isFinite(value) &&
Math.abs(value) <= MAX_SERIALIZABLE_MS
);
}
/** Trims, drops blanks, de-duplicates (first-occurrence order) and caps. */
export function uniqueTrimmedStrings(values: unknown, max: number): string[] {
if (!Array.isArray(values)) {
return [];
}
const seen = new Set<string>();
for (const value of values) {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed.length > 0) {
seen.add(trimmed);
}
}
return Array.from(seen).slice(0, max);
}
/** Validates and trims a request; `null` means "nothing to query". */
export function normalizeGuideWindow(
request: EpgGuideWindowRequest,
maxChannels = EPG_GUIDE_MAX_CHANNELS_PER_REQUEST
): NormalizedGuideWindow | null {
if (
!isUsableInstant(request.fromMs) ||
!isUsableInstant(request.toMs) ||
request.fromMs >= request.toMs ||
!Array.isArray(request.channelIds)
) {
return null;
}
const channelIds = uniqueTrimmedStrings(request.channelIds, maxChannels);
if (channelIds.length === 0) {
return null;
}
const sourceUrls = uniqueTrimmedStrings(
request.sourceUrls ?? [],
MAX_SOURCE_URLS_PER_REQUEST
);
return {
channelIds,
fromIso: new Date(request.fromMs).toISOString(),
toIso: new Date(request.toMs).toISOString(),
sourceUrls,
};
}
function escapeSqlLiteral(value: string): string {
return value.replace(/'/g, "''");
}
/**
* Plain-SQL twin of the Drizzle overlap predicate, used to prove the
* predicate's behaviour against a real SQLite engine rather than against a
* restatement of the builder's own logic. Must stay in lockstep with
* `guideWindowCondition`'s two `sql\`...\`` fragments.
*/
export function guideWindowOverlapSqlText(
fromIso: string,
toIso: string
): string {
return (
`datetime(start) < datetime('${escapeSqlLiteral(toIso)}') AND ` +
`datetime(stop) > datetime('${escapeSqlLiteral(fromIso)}')`
);
}
@@ -0,0 +1,47 @@
import { EpgProgram } from '@iptvnator/shared/interfaces';
/**
* Shared shape for a raw `epg_programs` row as read by both
* `EpgQueryService` and `EpgGuideQueryService`. Callers with additional
* columns (e.g. `id`) satisfy this structurally without extending it.
*/
export interface EpgProgramRow {
channelId: string;
start: string;
stop: string;
title: string;
description: string | null;
category: string | null;
iconUrl: string | null;
rating: string | null;
episodeNum: string | null;
}
/** Maps a raw DB row onto the public `EpgProgram` shape. No validation. */
export function toEpgProgramFromRow(row: EpgProgramRow): EpgProgram {
return {
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,
};
}
/**
* A programme is only usable once its start/stop are non-empty AND parse to
* a real instant — a malformed XMLTV timestamp must not reach the renderer
* as a bogus block.
*/
export function isValidEpgProgram(program: EpgProgram): boolean {
return Boolean(
program.start &&
program.stop &&
!Number.isNaN(new Date(program.start).getTime()) &&
!Number.isNaN(new Date(program.stop).getTime())
);
}
@@ -1,11 +1,9 @@
import { epgLogger } from '../util/epg-logger';
import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
import {
EpgChannelMetadata,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces';
import { getDatabase } from '../database/connection';
import * as schema from '../database/schema';
import { isValidEpgProgram, toEpgProgramFromRow } from './epg-program-row.util';
interface EpgProgramRow {
id: number;
@@ -215,11 +213,12 @@ export class EpgQueryService {
unmatchedIds = validIds.filter((id) => !(id in result));
if (unmatchedIds.length > 0) {
const scopedCandidates = await this.selectChannelLookupCandidates(
db,
unmatchedIds,
sourceUrls
);
const scopedCandidates =
await this.selectChannelLookupCandidates(
db,
unmatchedIds,
sourceUrls
);
const unresolvedIds = unmatchedIds.filter(
(channelId) =>
!this.resolveChannelMetadataCandidate(
@@ -435,7 +434,7 @@ export class EpgQueryService {
return {
...channel,
programs: programs.map(this.transformDbRowToEpgProgram),
programs: programs.map(toEpgProgramFromRow),
};
})
);
@@ -479,8 +478,8 @@ export class EpgQueryService {
continue;
}
const program = this.transformDbRowToEpgProgram(row);
if (this.isValidEpgProgram(program)) {
const program = toEpgProgramFromRow(row);
if (isValidEpgProgram(program)) {
result[row.channelId] = program;
matchedChannelIds.add(row.channelId);
}
@@ -513,8 +512,8 @@ export class EpgQueryService {
): Set<string> {
const matchedCandidateIds = new Set<string>();
for (const row of rows) {
const program = this.transformDbRowToEpgProgram(row);
if (!this.isValidEpgProgram(program)) {
const program = toEpgProgramFromRow(row);
if (!isValidEpgProgram(program)) {
continue;
}
@@ -538,24 +537,26 @@ export class EpgQueryService {
channelId: string,
sourceUrls: string[]
): Promise<EpgProgramRow[]> {
return db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
eq(schema.epgPrograms.channelId, channelId),
sourceUrls
return (
db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
eq(schema.epgPrograms.channelId, channelId),
sourceUrls
)
)
)
// Collapse identical slots from multiple sources in SQL so the
// 500-row cap counts distinct programmes, not duplicate rows.
.groupBy(
schema.epgPrograms.channelId,
schema.epgPrograms.start,
schema.epgPrograms.title
)
.orderBy(schema.epgPrograms.start)
.limit(500);
// Collapse identical slots from multiple sources in SQL so the
// 500-row cap counts distinct programmes, not duplicate rows.
.groupBy(
schema.epgPrograms.channelId,
schema.epgPrograms.start,
schema.epgPrograms.title
)
.orderBy(schema.epgPrograms.start)
.limit(500)
);
}
private async selectLegacyChannelPrograms(
@@ -600,23 +601,25 @@ export class EpgQueryService {
return [];
}
return db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
and(
inArray(schema.epgPrograms.channelId, channelIds),
this.isAiringAt(now)
) as SQL,
sourceUrls,
options
return (
db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
and(
inArray(schema.epgPrograms.channelId, channelIds),
this.isAiringAt(now)
) as SQL,
sourceUrls,
options
)
)
)
// One current row per channel so duplicate cross-source slots
// don't consume the per-channel cap and starve other channels.
.groupBy(schema.epgPrograms.channelId)
.limit(channelIds.length);
// One current row per channel so duplicate cross-source slots
// don't consume the per-channel cap and starve other channels.
.groupBy(schema.epgPrograms.channelId)
.limit(channelIds.length)
);
}
/**
@@ -701,9 +704,7 @@ export class EpgQueryService {
return db
.select()
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(condition, sourceUrls)
)
.where(this.withChannelSourceScope(condition, sourceUrls))
.limit(1);
}
@@ -725,11 +726,9 @@ export class EpgQueryService {
.select()
.from(schema.epgChannels)
.where(
this.withChannelSourceScope(
condition,
sourceUrls,
{ legacyOnly: true }
)
this.withChannelSourceScope(condition, sourceUrls, {
legacyOnly: true,
})
)
.limit(1);
}
@@ -874,7 +873,9 @@ export class EpgQueryService {
}
private channelHasProgramsForSourceScope(sourceUrls: string[]): SQL {
const sourceUrlValues = sourceUrls.map((sourceUrl) => sql`${sourceUrl}`);
const sourceUrlValues = sourceUrls.map(
(sourceUrl) => sql`${sourceUrl}`
);
return sql`EXISTS (
SELECT 1
@@ -931,8 +932,8 @@ export class EpgQueryService {
const seen = new Set<string>();
const programs: EpgProgram[] = [];
for (const row of rows) {
const program = this.transformDbRowToEpgProgram(row);
if (!this.isValidEpgProgram(program)) {
const program = toEpgProgramFromRow(row);
if (!isValidEpgProgram(program)) {
continue;
}
const key = `${program.channel}|${program.start}|${program.title}`;
@@ -945,29 +946,6 @@ export class EpgQueryService {
return programs;
}
private transformDbRowToEpgProgram(row: EpgProgramRow): EpgProgram {
return {
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,
};
}
private isValidEpgProgram(program: EpgProgram): boolean {
return Boolean(
program.start &&
program.stop &&
!Number.isNaN(new Date(program.start).getTime()) &&
!Number.isNaN(new Date(program.stop).getTime())
);
}
private async getMapping(
db: EpgDatabase,
channelKey: string
@@ -979,9 +957,7 @@ export class EpgQueryService {
epgChannelId: schema.epgChannelMappings.epgChannelId,
})
.from(schema.epgChannelMappings)
.where(
eq(schema.epgChannelMappings.channelKey, channelKey)
)
.where(eq(schema.epgChannelMappings.channelKey, channelKey))
.limit(1);
if (rows.length > 0) {
return rows[0].epgChannelId;
@@ -1022,10 +998,7 @@ export class EpgQueryService {
)
)
.where(eq(schema.content.epgChannelId, channelKey))
.orderBy(
schema.categories.playlistId,
schema.content.xtreamId
)
.orderBy(schema.categories.playlistId, schema.content.xtreamId)
.limit(1);
if (mapped.length > 0) {
return mapped[0].epgChannelId;