mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Harden EPG channel lookup and invalid rows
This commit is contained in:
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 [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() ||
|
||||
''
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user