Harden EPG channel lookup and invalid rows

This commit is contained in:
Eliran Sapir committed 2026-04-15 17:08:24 -04:00
1 parent e2b313084c
commit 3c73a4051b
9 files changed
+332 -23

No files matched your search

@@ -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<void> {
@@ -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<string, any>)[
'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<string, any>)[
'handleGetChannelPrograms'
]('id2e2cd03c90ad');
expect(programs).toHaveLength(1);
expect(programs[0].title).toBe('valid');
});
});
@@ -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 [];
+33
View File
@@ -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 `<programme>` entries before the matching `<channel>`, those programmes are skipped.
- Status: Identified, not patched, and still a possible storage-path issue even with a single EPG source.
@@ -0,0 +1,37 @@
import { EpgProgram } from 'shared-interfaces';
import { normalizeEpgPrograms } from './epg-program-normalization.util';
function buildProgram(overrides: Partial<EpgProgram> = {}): 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' })]);
});
});
@@ -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;
}
+5 -18
View File
@@ -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, {
@@ -0,0 +1,65 @@
import { Channel } from 'shared-interfaces';
import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util';
function buildChannel(overrides: Partial<Channel> = {}): 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');
});
});
@@ -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() ||
''
);
}
+2 -1
View File
@@ -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);
}