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