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 index a90fd65e6..14c30f062 100644 --- 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 @@ -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, {}); + }); }); 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 index 9aba14dc6..924cba52a 100644 --- a/apps/electron-backend/src/app/events/epg-guide-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.ts @@ -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; -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> { - 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 { - 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 { const result: Record = {}; - 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 { + private groupPrograms(rows: EpgProgramRow[]): 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) { + 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; diff --git a/apps/electron-backend/src/app/events/epg-guide-window.util.ts b/apps/electron-backend/src/app/events/epg-guide-window.util.ts new file mode 100644 index 000000000..90cca23a0 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide-window.util.ts @@ -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(); + 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)}')` + ); +} diff --git a/apps/electron-backend/src/app/events/epg-program-row.util.ts b/apps/electron-backend/src/app/events/epg-program-row.util.ts new file mode 100644 index 000000000..d4ad31efe --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-program-row.util.ts @@ -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()) + ); +} diff --git a/apps/electron-backend/src/app/events/epg-query.service.ts b/apps/electron-backend/src/app/events/epg-query.service.ts index c23764644..03494401f 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -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 { const matchedCandidateIds = new Set(); 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 { - 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(); 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;