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..60bbcc703 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,100 @@ 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 from = jest.fn(); + const where = jest.fn(); + const orderBy = jest.fn(); + const limit = jest.fn(); + + select.mockImplementation(() => ({ from })); + from.mockImplementation((table: { _: { name: string } }) => { + if (table._.name === 'epg_programs') { + return { where }; + } + + return { where }; + }); + + where + .mockReturnValueOnce({ orderBy }) + .mockReturnValueOnce({ limit }); + orderBy.mockReturnValueOnce({ limit }); + limit + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }]) + .mockResolvedValueOnce([ + { + 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, + }, + ]); + + 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/epg-investigation-issues.md b/epg-investigation-issues.md new file mode 100644 index 000000000..2af0f9c21 --- /dev/null +++ b/epg-investigation-issues.md @@ -0,0 +1,33 @@ +# EPG Investigation Issues + +These are credible EPG problems identified during investigation, but they do not yet explain the current single-source hashed-`tvg-id` mismatch issue. + +## 1. Redirected gzipped EPG URLs + +- Problem: EPG fetch followed HTTP redirects, but gzip detection originally depended on the entered URL rather than the final response. +- Impact: Redirects from a plain URL to a `.gz` payload could fetch successfully but fail during parsing. +- Status: Fixed. + +## 2. Inconsistent M3U EPG lookup fallback order + +- Problem: Different M3U code paths used different lookup keys when resolving EPG, such as `tvg-id || name` in one place and `tvg-id || tvg-name || name` in another. +- Impact: Some channels could resolve EPG in one surface but not another. +- Status: Patched, but not believed to be the root cause of the current issue. + +## 3. Case-sensitive `tvg-id` mismatch risk + +- Problem: XMLTV channel ids and M3U `tvg-id` values may differ only by case. +- Impact: Exact id lookup can miss valid EPG rows unless the backend also tries a case-insensitive channel-id match. +- Status: Patched, but not believed to be the root cause of the current issue. + +## 4. Multi-source EPG channel row overwrite / deletion risk + +- Problem: `epg_channels.id` is globally unique while each row stores only one `source_url`. Importing another source with the same channel id can overwrite ownership, and clearing one source can cascade-delete shared programs. +- Impact: With multiple EPG URLs configured, channels may appear to lose EPG unpredictably over time. +- Status: Identified, not patched in this investigation, and not applicable to the current single-source report. + +## 5. Programme-before-channel parse ordering risk + +- Problem: The worker only inserts programmes for channel ids already seen in the current parse. +- Impact: If an XMLTV feed emits `` entries before the matching ``, those programmes are skipped. +- Status: Identified, not patched, and still a possible storage-path issue even with a single EPG source. 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); }