diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index a16ccaed0..20c2576f6 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -2,6 +2,7 @@ import type EpgEventsType from './epg.events'; const mockWorkerInstances: any[] = []; const resolveWorkerRuntimeBootstrap = jest.fn(); +const getDatabase = jest.fn(); jest.mock('electron', () => ({ app: { @@ -39,7 +40,7 @@ jest.mock('../workers/worker-runtime-paths', () => ({ })); jest.mock('../database/connection', () => ({ - getDatabase: jest.fn(), + getDatabase: (...args: unknown[]) => getDatabase(...args), })); describe('EpgEvents', () => { @@ -67,6 +68,7 @@ describe('EpgEvents', () => { afterEach(() => { consoleLogSpy.mockRestore(); consoleErrorSpy.mockRestore(); + getDatabase.mockReset(); }); async function flushPromises(): Promise { @@ -128,4 +130,107 @@ describe('EpgEvents', () => { message: expect.stringContaining('epg-parser.worker.js'), }); }); + + it('falls back to case-insensitive channel id lookup for EPG programs', async () => { + const select = jest.fn(); + const programLimitExact = jest.fn().mockResolvedValue([]); + const channelLimit = jest + .fn() + .mockResolvedValue([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }]); + const programLimitResolved = jest.fn().mockResolvedValue([ + { + id: 1, + channelId: 'BBC.ONE.UK', + start: '2026-04-14T10:00:00Z', + stop: '2026-04-14T11:00:00Z', + title: 'News', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + }, + ]); + + const from = jest + .fn() + .mockReturnValueOnce({ + where: jest.fn().mockReturnValue({ + orderBy: jest.fn().mockReturnValue({ + limit: programLimitExact, + }), + }), + }) + .mockReturnValueOnce({ + where: jest.fn().mockReturnValue({ + limit: channelLimit, + }), + }) + .mockReturnValueOnce({ + where: jest.fn().mockReturnValue({ + orderBy: jest.fn().mockReturnValue({ + limit: programLimitResolved, + }), + }), + }); + + select.mockImplementation(() => ({ from })); + + getDatabase.mockResolvedValue({ select }); + + const programs = await (EpgEvents as unknown as Record)[ + 'handleGetChannelPrograms' + ]('bbc.one.uk'); + + expect(programs).toHaveLength(1); + expect(programs[0].channel).toBe('BBC.ONE.UK'); + }); + + it('drops malformed EPG rows with invalid stop dates', async () => { + const select = jest.fn(); + const from = jest.fn(); + const where = jest.fn(); + const orderBy = jest.fn(); + const limit = jest.fn(); + + select.mockImplementation(() => ({ from })); + from.mockReturnValue({ where }); + where.mockReturnValue({ orderBy }); + orderBy.mockReturnValue({ limit }); + limit.mockResolvedValue([ + { + id: 1, + channelId: 'id2e2cd03c90ad', + start: '2026-04-14T20:00:00+00:00', + stop: '2026-04-14T21:00:00+00:00', + title: 'valid', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + }, + { + id: 2, + channelId: 'id2e2cd03c90ad', + start: '2026-04-14T21:00:00+00:00', + stop: '', + title: 'invalid', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + }, + ]); + + getDatabase.mockResolvedValue({ select }); + + const programs = await (EpgEvents as unknown as Record)[ + 'handleGetChannelPrograms' + ]('id2e2cd03c90ad'); + + expect(programs).toHaveLength(1); + expect(programs[0].title).toBe('valid'); + }); }); diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 0356c1a26..5b174dfc2 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -410,6 +410,15 @@ export default class EpgEvents { }; } + private static isValidEpgProgram(program: EpgProgram): boolean { + return Boolean( + program.start && + program.stop && + !Number.isNaN(new Date(program.start).getTime()) && + !Number.isNaN(new Date(program.stop).getTime()) + ); + } + /** * Get programs for a specific channel from database */ @@ -433,12 +442,39 @@ export default class EpgEvents { .limit(500); if (results.length > 0) { - return results.map(this.transformDbRowToEpgProgram); + return results + .map(this.transformDbRowToEpgProgram) + .filter(this.isValidEpgProgram); + } + + // Some playlists provide the right tvg-id with different casing than + // the XMLTV feed. Resolve the canonical channel row before giving up. + let channel = await db + .select() + .from(schema.epgChannels) + .where( + sql`${schema.epgChannels.id} = ${trimmedChannelId} COLLATE NOCASE` + ) + .limit(1); + + if (channel.length > 0) { + results = await db + .select() + .from(schema.epgPrograms) + .where(eq(schema.epgPrograms.channelId, channel[0].id)) + .orderBy(schema.epgPrograms.start) + .limit(500); + + if (results.length > 0) { + return results + .map(this.transformDbRowToEpgProgram) + .filter(this.isValidEpgProgram); + } } // Try exact display name match before giving up. Using wildcard LIKE // here can scan the whole table on the Electron main process. - let channel = await db + channel = await db .select() .from(schema.epgChannels) .where(eq(schema.epgChannels.displayName, trimmedChannelId)) @@ -462,7 +498,9 @@ export default class EpgEvents { .orderBy(schema.epgPrograms.start) .limit(500); - return results.map(this.transformDbRowToEpgProgram); + return results + .map(this.transformDbRowToEpgProgram) + .filter(this.isValidEpgProgram); } return []; diff --git a/apps/electron-backend/src/app/workers/epg-parser.worker.ts b/apps/electron-backend/src/app/workers/epg-parser.worker.ts index 5bb8d7eea..fd2fffa62 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -1,10 +1,15 @@ import type BetterSqlite3 from 'better-sqlite3'; import { existsSync, mkdirSync } from 'fs'; import { getIptvnatorDatabasePath } from 'database-path-utils'; -import { SaxesParser, SaxesTagPlain } from 'saxes'; import { Readable } from 'stream'; import { parentPort, workerData } from 'worker_threads'; import { createGunzip } from 'zlib'; +import { + ParsedChannel, + ParsedProgram, + StreamingEpgParser, +} from './epg-streaming-parser'; +import { shouldGunzipEpgResponse } from './epg-response-utils'; import { getNativeModuleSearchPaths, getWorkerDataNativeModuleSearchPaths, @@ -38,51 +43,6 @@ function loadBetterSqlite3(): typeof BetterSqlite3 { Database = loadBetterSqlite3(); -/** - * Internal parsing types with arrays for XML parsing - * These are different from the flat EpgProgram interface used by the frontend - */ -interface ParsedTextValue { - lang: string; - value: string; -} - -interface ParsedIcon { - src: string; - width?: number; - height?: number; -} - -interface ParsedRating { - system: string; - value: string; -} - -interface ParsedEpisodeNum { - system: string; - value: string; -} - -interface ParsedChannel { - id: string; - displayName: ParsedTextValue[]; - icon: ParsedIcon[]; - url: string[]; -} - -interface ParsedProgram { - start: string; - stop: string; - channel: string; - title: ParsedTextValue[]; - desc: ParsedTextValue[]; - category: ParsedTextValue[]; - date: string; - episodeNum: ParsedEpisodeNum[]; - icon: ParsedIcon[]; - rating: ParsedRating[]; -} - /** * Streaming EPG Parser Worker * Uses SAX parsing to process XML incrementally without loading entire file into memory. @@ -243,285 +203,12 @@ class EpgDatabase { } } -/** - * Parse XMLTV datetime format to ISO string - * Format: YYYYMMDDHHmmss +HHMM or YYYYMMDDHHmmss - */ -function parseXmltvDate(dateStr: string): string { - if (!dateStr) return ''; - - const match = dateStr.match( - /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/ - ); - - if (!match) return dateStr; - - const [, year, month, day, hour, minute, second, tz] = match; - - let isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}`; - - if (tz) { - isoString += `${tz.slice(0, 3)}:${tz.slice(3)}`; - } else { - isoString += 'Z'; - } - - return isoString; -} - -/** - * Streaming EPG parser using SAX - */ -class StreamingEpgParser { - private parser: SaxesParser; - private channels: ParsedChannel[] = []; - private programs: ParsedProgram[] = []; - private totalChannels = 0; - private totalPrograms = 0; - - // Current element being parsed - private currentChannel: Partial | null = null; - private currentProgram: Partial | null = null; - private currentTextContent = ''; - private currentLang = ''; - - // For nested elements - private elementStack: string[] = []; - - constructor( - private onChannelsBatch: (channels: ParsedChannel[]) => void, - private onProgramsBatch: (programs: ParsedProgram[]) => void, - private onProgress: (channels: number, programs: number) => void - ) { - this.parser = new SaxesParser(); - this.setupParser(); - } - - private setupParser(): void { - this.parser.on('opentag', (tag: SaxesTagPlain) => { - this.elementStack.push(tag.name); - this.currentTextContent = ''; - - switch (tag.name) { - case 'channel': - this.currentChannel = { - id: (tag.attributes['id'] as string) || '', - displayName: [], - icon: [], - url: [], - }; - break; - - case 'programme': - this.currentProgram = { - start: parseXmltvDate( - (tag.attributes['start'] as string) || '' - ), - stop: parseXmltvDate( - (tag.attributes['stop'] as string) || '' - ), - channel: (tag.attributes['channel'] as string) || '', - title: [], - desc: [], - category: [], - date: '', - episodeNum: [], - icon: [], - rating: [], - }; - break; - - case 'icon': - if (this.currentChannel) { - this.currentChannel.icon!.push({ - src: (tag.attributes['src'] as string) || '', - width: tag.attributes['width'] - ? parseInt(tag.attributes['width'] as string) - : undefined, - height: tag.attributes['height'] - ? parseInt(tag.attributes['height'] as string) - : undefined, - }); - } else if (this.currentProgram) { - this.currentProgram.icon!.push({ - src: (tag.attributes['src'] as string) || '', - width: tag.attributes['width'] - ? parseInt(tag.attributes['width'] as string) - : undefined, - height: tag.attributes['height'] - ? parseInt(tag.attributes['height'] as string) - : undefined, - }); - } - break; - - case 'display-name': - case 'title': - case 'desc': - case 'category': - this.currentLang = (tag.attributes['lang'] as string) || ''; - break; - - case 'rating': - if (this.currentProgram) { - const system = - (tag.attributes['system'] as string) || ''; - this.currentProgram.rating!.push({ system, value: '' }); - } - break; - - case 'episode-num': - if (this.currentProgram) { - const system = - (tag.attributes['system'] as string) || ''; - this.currentProgram.episodeNum!.push({ - system, - value: '', - }); - } - break; - } - }); - - this.parser.on('text', (text: string) => { - this.currentTextContent += text; - }); - - this.parser.on('closetag', (tag: SaxesTagPlain) => { - const text = this.currentTextContent.trim(); - - if (this.currentChannel) { - switch (tag.name) { - case 'display-name': - this.currentChannel.displayName!.push({ - lang: this.currentLang, - value: text, - }); - break; - case 'url': - if (text) this.currentChannel.url!.push(text); - break; - case 'channel': - this.channels.push( - this.currentChannel as ParsedChannel - ); - this.totalChannels++; - this.currentChannel = null; - - if (this.channels.length >= CHANNEL_BATCH_SIZE) { - this.flushChannels(); - } - break; - } - } - - if (this.currentProgram) { - switch (tag.name) { - case 'title': - this.currentProgram.title!.push({ - lang: this.currentLang, - value: text, - }); - break; - case 'desc': - this.currentProgram.desc!.push({ - lang: this.currentLang, - value: text, - }); - break; - case 'category': - this.currentProgram.category!.push({ - lang: this.currentLang, - value: text, - }); - break; - case 'date': - this.currentProgram.date = text; - break; - case 'value': - if ( - this.elementStack.includes('rating') && - this.currentProgram.rating!.length > 0 - ) { - this.currentProgram.rating![ - this.currentProgram.rating!.length - 1 - ].value = text; - } - break; - case 'episode-num': - if (this.currentProgram.episodeNum!.length > 0) { - this.currentProgram.episodeNum![ - this.currentProgram.episodeNum!.length - 1 - ].value = text; - } - break; - case 'programme': - this.programs.push( - this.currentProgram as ParsedProgram - ); - this.totalPrograms++; - - if (this.programs.length >= PROGRAM_BATCH_SIZE) { - this.flushChannels(); - this.flushPrograms(); - } - this.currentProgram = null; - break; - } - } - - this.elementStack.pop(); - this.currentTextContent = ''; - }); - - this.parser.on('error', (err: Error) => { - console.error(loggerLabel, 'Parser error:', err.message); - }); - } - - private flushChannels(): void { - if (this.channels.length > 0) { - this.onChannelsBatch([...this.channels]); - this.channels = []; - this.onProgress(this.totalChannels, this.totalPrograms); - } - } - - private flushPrograms(): void { - if (this.programs.length > 0) { - this.onProgramsBatch([...this.programs]); - this.programs = []; - this.onProgress(this.totalChannels, this.totalPrograms); - } - } - - write(chunk: string): void { - this.parser.write(chunk); - } - - finish(): { totalChannels: number; totalPrograms: number } { - this.parser.close(); - this.flushChannels(); - this.flushPrograms(); - - return { - totalChannels: this.totalChannels, - totalPrograms: this.totalPrograms, - }; - } -} - /** * Fetches and parses EPG data from URL using streaming * Inserts directly into SQLite to avoid blocking main thread */ async function fetchAndParseEpgStreaming(url: string): Promise { - const isGzipped = url.endsWith('.gz'); - - console.log( - loggerLabel, - `Fetching EPG from ${url} (gzipped: ${isGzipped})` - ); + console.log(loggerLabel, `Fetching EPG from ${url}`); // Create database connection in worker const epgDb = new EpgDatabase(); @@ -532,6 +219,19 @@ async function fetchAndParseEpgStreaming(url: string): Promise { epgDb.clearSourceData(url); const response = await fetch(url.trim()); + const isGzipped = shouldGunzipEpgResponse(url, response); + + if (response.url && response.url !== url) { + console.log( + loggerLabel, + `Resolved EPG redirect: ${url} -> ${response.url}` + ); + } + + console.log( + loggerLabel, + `EPG response detected as gzipped: ${isGzipped}` + ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); @@ -557,7 +257,9 @@ async function fetchAndParseEpgStreaming(url: string): Promise { stats: { totalChannels, totalPrograms }, }; parentPort?.postMessage(response); - } + }, + CHANNEL_BATCH_SIZE, + PROGRAM_BATCH_SIZE ); // Convert web stream to Node.js stream diff --git a/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts b/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts new file mode 100644 index 000000000..99962c77a --- /dev/null +++ b/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts @@ -0,0 +1,59 @@ +import { shouldGunzipEpgResponse } from './epg-response-utils'; + +describe('shouldGunzipEpgResponse', () => { + it('returns true for original .gz URLs', () => { + expect( + shouldGunzipEpgResponse('https://example.com/guide.xml.gz', { + headers: new Headers(), + url: 'https://example.com/guide.xml.gz', + }) + ).toBe(true); + }); + + it('returns true when a redirect lands on a .gz URL', () => { + expect( + shouldGunzipEpgResponse('http://epg.vcboy.com', { + headers: new Headers(), + url: 'http://iptv-worker.sapireli.workers.dev/epg.xml.gz', + }) + ).toBe(true); + }); + + it('returns false for gzip content-encoding without a gzip payload', () => { + expect( + shouldGunzipEpgResponse('https://example.com/guide', { + headers: new Headers([['content-encoding', 'gzip']]), + url: 'https://example.com/guide', + }) + ).toBe(false); + }); + + it('returns true for gzip mime types', () => { + expect( + shouldGunzipEpgResponse('https://example.com/guide', { + headers: new Headers([['content-type', 'application/gzip']]), + url: 'https://example.com/guide', + }) + ).toBe(true); + }); + + it('returns true when content-disposition advertises a .gz filename', () => { + expect( + shouldGunzipEpgResponse('https://example.com/guide', { + headers: new Headers([ + ['content-disposition', 'attachment; filename="guide.xml.gz"'], + ]), + url: 'https://example.com/guide', + }) + ).toBe(true); + }); + + it('returns false for plain XML responses', () => { + expect( + shouldGunzipEpgResponse('https://example.com/guide.xml', { + headers: new Headers([['content-type', 'application/xml']]), + url: 'https://example.com/guide.xml', + }) + ).toBe(false); + }); +}); diff --git a/apps/electron-backend/src/app/workers/epg-response-utils.ts b/apps/electron-backend/src/app/workers/epg-response-utils.ts new file mode 100644 index 000000000..55db43d99 --- /dev/null +++ b/apps/electron-backend/src/app/workers/epg-response-utils.ts @@ -0,0 +1,39 @@ +function hasGzipPath(url: string | null | undefined): boolean { + if (!url) { + return false; + } + + try { + return new URL(url).pathname.toLowerCase().endsWith('.gz'); + } catch { + return url.toLowerCase().endsWith('.gz'); + } +} + +/** + * Detect whether an EPG response should be gunzipped. + * Some providers redirect plain-looking URLs to a `.gz` payload. + */ +export function shouldGunzipEpgResponse( + originalUrl: string, + response: { headers: Headers; url?: string } +): boolean { + if (hasGzipPath(originalUrl) || hasGzipPath(response.url)) { + return true; + } + + const contentType = response.headers.get('content-type'); + if ( + contentType && + /(application\/gzip|application\/x-gzip)/i.test(contentType) + ) { + return true; + } + + const contentDisposition = response.headers.get('content-disposition'); + if (contentDisposition?.toLowerCase().includes('.gz')) { + return true; + } + + return false; +} diff --git a/apps/electron-backend/src/app/workers/epg-streaming-parser.spec.ts b/apps/electron-backend/src/app/workers/epg-streaming-parser.spec.ts new file mode 100644 index 000000000..a36a3320f --- /dev/null +++ b/apps/electron-backend/src/app/workers/epg-streaming-parser.spec.ts @@ -0,0 +1,57 @@ +import { + parseXmltvDate, + StreamingEpgParser, + type ParsedChannel, + type ParsedProgram, +} from './epg-streaming-parser'; + +describe('parseXmltvDate', () => { + it('normalizes XMLTV timestamps with timezone offsets', () => { + expect(parseXmltvDate('20260415053700 +0000')).toBe( + '2026-04-15T05:37:00+00:00' + ); + }); +}); + +describe('StreamingEpgParser', () => { + it('flushes pending channels before the first programme batch', () => { + const callbackOrder: string[] = []; + const channelIdsByBatch: string[][] = []; + const programmeChannelsByBatch: string[][] = []; + + const parser = new StreamingEpgParser( + (channels: ParsedChannel[]) => { + callbackOrder.push('channels'); + channelIdsByBatch.push(channels.map((channel) => channel.id)); + }, + (programs: ParsedProgram[]) => { + callbackOrder.push('programs'); + programmeChannelsByBatch.push( + programs.map((program) => program.channel) + ); + }, + () => undefined + ); + + const xml = [ + '', + '', + ...Array.from({ length: 101 }, (_, index) => { + const id = `channel-${index + 1}`; + return `${id}`; + }), + 'Late channel', + '', + ].join(''); + + parser.write(xml); + parser.finish(); + + expect(callbackOrder).toEqual(['channels', 'channels', 'programs']); + expect(channelIdsByBatch).toEqual([ + Array.from({ length: 100 }, (_, index) => `channel-${index + 1}`), + ['channel-101'], + ]); + expect(programmeChannelsByBatch).toEqual([['channel-101']]); + }); +}); diff --git a/apps/electron-backend/src/app/workers/epg-streaming-parser.ts b/apps/electron-backend/src/app/workers/epg-streaming-parser.ts new file mode 100644 index 000000000..7c82e7d00 --- /dev/null +++ b/apps/electron-backend/src/app/workers/epg-streaming-parser.ts @@ -0,0 +1,309 @@ +import { SaxesParser, SaxesTagPlain } from 'saxes'; + +export interface ParsedTextValue { + lang: string; + value: string; +} + +export interface ParsedIcon { + src: string; + width?: number; + height?: number; +} + +export interface ParsedRating { + system: string; + value: string; +} + +export interface ParsedEpisodeNum { + system: string; + value: string; +} + +export interface ParsedChannel { + id: string; + displayName: ParsedTextValue[]; + icon: ParsedIcon[]; + url: string[]; +} + +export interface ParsedProgram { + start: string; + stop: string; + channel: string; + title: ParsedTextValue[]; + desc: ParsedTextValue[]; + category: ParsedTextValue[]; + date: string; + episodeNum: ParsedEpisodeNum[]; + icon: ParsedIcon[]; + rating: ParsedRating[]; +} + +/** + * Parse XMLTV datetime format to ISO string + * Format: YYYYMMDDHHmmss +HHMM or YYYYMMDDHHmmss + */ +export function parseXmltvDate(dateStr: string): string { + if (!dateStr) return ''; + + const match = dateStr.match( + /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/ + ); + + if (!match) return dateStr; + + const [, year, month, day, hour, minute, second, tz] = match; + + let isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}`; + + if (tz) { + isoString += `${tz.slice(0, 3)}:${tz.slice(3)}`; + } else { + isoString += 'Z'; + } + + return isoString; +} + +/** + * Streaming EPG parser using SAX + */ +export class StreamingEpgParser { + private parser: SaxesParser; + private channels: ParsedChannel[] = []; + private programs: ParsedProgram[] = []; + private totalChannels = 0; + private totalPrograms = 0; + + // Current element being parsed + private currentChannel: Partial | null = null; + private currentProgram: Partial | null = null; + private currentTextContent = ''; + private currentLang = ''; + + // For nested elements + private elementStack: string[] = []; + + constructor( + private readonly onChannelsBatch: (channels: ParsedChannel[]) => void, + private readonly onProgramsBatch: (programs: ParsedProgram[]) => void, + private readonly onProgress: (channels: number, programs: number) => void, + private readonly channelBatchSize = 100, + private readonly programBatchSize = 1000 + ) { + this.parser = new SaxesParser(); + this.setupParser(); + } + + private setupParser(): void { + this.parser.on('opentag', (tag: SaxesTagPlain) => { + this.elementStack.push(tag.name); + this.currentTextContent = ''; + + switch (tag.name) { + case 'channel': + this.currentChannel = { + id: (tag.attributes['id'] as string) || '', + displayName: [], + icon: [], + url: [], + }; + break; + + case 'programme': + // XMLTV feeds typically emit all entries before + // the first . Flush any pending channel batch + // so downstream program consumers can resolve late-channel + // IDs immediately instead of dropping the first rows. + this.flushChannels(); + this.currentProgram = { + start: parseXmltvDate( + (tag.attributes['start'] as string) || '' + ), + stop: parseXmltvDate( + (tag.attributes['stop'] as string) || '' + ), + channel: (tag.attributes['channel'] as string) || '', + title: [], + desc: [], + category: [], + date: '', + episodeNum: [], + icon: [], + rating: [], + }; + break; + + case 'icon': + if (this.currentChannel) { + this.currentChannel.icon!.push({ + src: (tag.attributes['src'] as string) || '', + width: tag.attributes['width'] + ? parseInt(tag.attributes['width'] as string) + : undefined, + height: tag.attributes['height'] + ? parseInt(tag.attributes['height'] as string) + : undefined, + }); + } else if (this.currentProgram) { + this.currentProgram.icon!.push({ + src: (tag.attributes['src'] as string) || '', + width: tag.attributes['width'] + ? parseInt(tag.attributes['width'] as string) + : undefined, + height: tag.attributes['height'] + ? parseInt(tag.attributes['height'] as string) + : undefined, + }); + } + break; + + case 'display-name': + case 'title': + case 'desc': + case 'category': + this.currentLang = (tag.attributes['lang'] as string) || ''; + break; + + case 'rating': + if (this.currentProgram) { + const system = + (tag.attributes['system'] as string) || ''; + this.currentProgram.rating!.push({ system, value: '' }); + } + break; + + case 'episode-num': + if (this.currentProgram) { + const system = + (tag.attributes['system'] as string) || ''; + this.currentProgram.episodeNum!.push({ + system, + value: '', + }); + } + break; + } + }); + + this.parser.on('text', (text: string) => { + this.currentTextContent += text; + }); + + this.parser.on('closetag', (tag: SaxesTagPlain) => { + const text = this.currentTextContent.trim(); + + if (this.currentChannel) { + switch (tag.name) { + case 'display-name': + this.currentChannel.displayName!.push({ + lang: this.currentLang, + value: text, + }); + break; + case 'url': + if (text) this.currentChannel.url!.push(text); + break; + case 'channel': + this.channels.push(this.currentChannel as ParsedChannel); + this.totalChannels++; + this.currentChannel = null; + + if (this.channels.length >= this.channelBatchSize) { + this.flushChannels(); + } + break; + } + } + + if (this.currentProgram) { + switch (tag.name) { + case 'title': + this.currentProgram.title!.push({ + lang: this.currentLang, + value: text, + }); + break; + case 'desc': + this.currentProgram.desc!.push({ + lang: this.currentLang, + value: text, + }); + break; + case 'category': + this.currentProgram.category!.push({ + lang: this.currentLang, + value: text, + }); + break; + case 'date': + this.currentProgram.date = text; + break; + case 'value': + if ( + this.elementStack.includes('rating') && + this.currentProgram.rating!.length > 0 + ) { + this.currentProgram.rating![ + this.currentProgram.rating!.length - 1 + ].value = text; + } + break; + case 'episode-num': + if (this.currentProgram.episodeNum!.length > 0) { + this.currentProgram.episodeNum![ + this.currentProgram.episodeNum!.length - 1 + ].value = text; + } + break; + case 'programme': + this.programs.push(this.currentProgram as ParsedProgram); + this.totalPrograms++; + + if (this.programs.length >= this.programBatchSize) { + this.flushChannels(); + this.flushPrograms(); + } + this.currentProgram = null; + break; + } + } + + this.elementStack.pop(); + this.currentTextContent = ''; + }); + } + + private flushChannels(): void { + if (this.channels.length > 0) { + this.onChannelsBatch([...this.channels]); + this.channels = []; + this.onProgress(this.totalChannels, this.totalPrograms); + } + } + + private flushPrograms(): void { + if (this.programs.length > 0) { + this.onProgramsBatch([...this.programs]); + this.programs = []; + this.onProgress(this.totalChannels, this.totalPrograms); + } + } + + write(chunk: string): void { + this.parser.write(chunk); + } + + finish(): { totalChannels: number; totalPrograms: number } { + this.parser.close(); + this.flushChannels(); + this.flushPrograms(); + + return { + totalChannels: this.totalChannels, + totalPrograms: this.totalPrograms, + }; + } +} diff --git a/libs/epg/data-access/src/lib/epg-program-normalization.util.spec.ts b/libs/epg/data-access/src/lib/epg-program-normalization.util.spec.ts new file mode 100644 index 000000000..12b05bf57 --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-program-normalization.util.spec.ts @@ -0,0 +1,37 @@ +import { EpgProgram } from 'shared-interfaces'; +import { normalizeEpgPrograms } from './epg-program-normalization.util'; + +function buildProgram(overrides: Partial = {}): EpgProgram { + return { + start: '2026-04-15T20:00:00Z', + stop: '2026-04-15T21:00:00Z', + channel: 'channel-1', + title: 'Sample', + desc: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + ...overrides, + }; +} + +describe('normalizeEpgPrograms', () => { + it('keeps valid rows and normalizes dates', () => { + expect(normalizeEpgPrograms([buildProgram()])).toEqual([ + expect.objectContaining({ + start: '2026-04-15T20:00:00.000Z', + stop: '2026-04-15T21:00:00.000Z', + }), + ]); + }); + + it('drops rows with invalid stop dates', () => { + expect( + normalizeEpgPrograms([ + buildProgram({ title: 'ok' }), + buildProgram({ title: 'bad', stop: '' }), + ]) + ).toEqual([expect.objectContaining({ title: 'ok' })]); + }); +}); diff --git a/libs/epg/data-access/src/lib/epg-program-normalization.util.ts b/libs/epg/data-access/src/lib/epg-program-normalization.util.ts new file mode 100644 index 000000000..b46958160 --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-program-normalization.util.ts @@ -0,0 +1,38 @@ +import { EpgProgram } from 'shared-interfaces'; + +function toIsoDate(value: string | undefined): string | null { + if (!value) { + return null; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + + return date.toISOString(); +} + +/** + * Drop malformed EPG rows instead of failing the whole channel request. + */ +export function normalizeEpgPrograms(programs: EpgProgram[]): EpgProgram[] { + const normalizedPrograms: EpgProgram[] = []; + + for (const program of programs) { + const start = toIsoDate(program.start); + const stop = toIsoDate(program.stop); + + if (!start || !stop) { + continue; + } + + normalizedPrograms.push({ + ...program, + start, + stop, + }); + } + + return normalizedPrograms; +} diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index 9e41ecd44..154ebc081 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -4,6 +4,7 @@ import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs'; import { catchError, map, tap, timeout } from 'rxjs/operators'; import { EpgProgram } from 'shared-interfaces'; +import { normalizeEpgPrograms } from './epg-program-normalization.util'; interface CachedProgram { program: EpgProgram | null; @@ -69,13 +70,7 @@ export class EpgService { from(window.electron.getChannelPrograms(channelId)) .pipe( timeout(3000), - map((programs: EpgProgram[]) => - programs.map((program) => ({ - ...program, - start: new Date(program.start).toISOString(), - stop: new Date(program.stop).toISOString(), - })) - ), + map((programs: EpgProgram[]) => normalizeEpgPrograms(programs)), catchError((err) => { console.error('EPG get programs error:', err); this.showErrorSnackbar(); @@ -123,8 +118,9 @@ export class EpgService { // Fetch from backend return from(window.electron.getChannelPrograms(channelId)).pipe( + map((programs: EpgProgram[]) => normalizeEpgPrograms(programs)), map((programs: EpgProgram[]) => { - if (!programs || programs.length === 0) { + if (!programs.length) { this.programCache.set(channelId, { program: null, timestamp: now, @@ -132,16 +128,7 @@ export class EpgService { return null; } - // Normalize date formats to ISO strings for consistency - const transformedPrograms = programs.map((program) => ({ - ...program, - start: new Date(program.start).toISOString(), - stop: new Date(program.stop).toISOString(), - })); - - // Find current program from transformed programs - const currentProgram = - this.findCurrentProgram(transformedPrograms); + const currentProgram = this.findCurrentProgram(programs); // Cache the result this.programCache.set(channelId, { diff --git a/libs/m3u-state/src/lib/channel-epg-lookup.util.spec.ts b/libs/m3u-state/src/lib/channel-epg-lookup.util.spec.ts new file mode 100644 index 000000000..235f777a5 --- /dev/null +++ b/libs/m3u-state/src/lib/channel-epg-lookup.util.spec.ts @@ -0,0 +1,65 @@ +import { Channel } from 'shared-interfaces'; +import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util'; + +function buildChannel(overrides: Partial = {}): Channel { + return { + id: 'channel-1', + url: 'https://example.com/live.m3u8', + name: 'Fallback Name', + group: { title: 'News' }, + tvg: { + id: 'epg-id', + name: 'Guide Name', + url: '', + logo: '', + rec: '', + }, + http: { + referrer: '', + 'user-agent': '', + origin: '', + }, + radio: 'false', + ...overrides, + }; +} + +describe('resolveChannelEpgLookupKey', () => { + it('prefers tvg id when present', () => { + expect(resolveChannelEpgLookupKey(buildChannel())).toBe('epg-id'); + }); + + it('falls back to tvg name before channel name', () => { + expect( + resolveChannelEpgLookupKey( + buildChannel({ + tvg: { + id: ' ', + name: 'Guide Name', + url: '', + logo: '', + rec: '', + }, + name: 'Channel Name', + }) + ) + ).toBe('Guide Name'); + }); + + it('falls back to channel name when tvg fields are empty', () => { + expect( + resolveChannelEpgLookupKey( + buildChannel({ + tvg: { + id: ' ', + name: ' ', + url: '', + logo: '', + rec: '', + }, + name: 'Channel Name', + }) + ) + ).toBe('Channel Name'); + }); +}); diff --git a/libs/m3u-state/src/lib/channel-epg-lookup.util.ts b/libs/m3u-state/src/lib/channel-epg-lookup.util.ts new file mode 100644 index 000000000..52e1ee69d --- /dev/null +++ b/libs/m3u-state/src/lib/channel-epg-lookup.util.ts @@ -0,0 +1,12 @@ +import { Channel } from 'shared-interfaces'; + +export function resolveChannelEpgLookupKey( + channel: Channel | null | undefined +): string { + return ( + channel?.tvg?.id?.trim() || + channel?.tvg?.name?.trim() || + channel?.name?.trim() || + '' + ); +} diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 074dab8d4..0e335ab41 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -37,6 +37,7 @@ import { selectChannels, selectFavorites, } from './selectors'; +import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util'; import { buildExternalPlayerPayload } from './external-player-payload.util'; @Injectable({ providedIn: 'any' }) @@ -152,7 +153,7 @@ export class PlaylistEffects { const { channel } = action; // Use modern EPG service to get channel programs - const channelId = channel.tvg?.id || channel.name; + const channelId = resolveChannelEpgLookupKey(channel); if (channelId) { this.epgService.getChannelPrograms(channelId); }