fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row

EpgQueryService.getChannelMetadata swallowed database errors into {}, so the
guide's coverage read could publish an empty set after a transient failure;
the guide now uses the strict resolveChannelMetadata (getChannelMetadata is
the fail-soft wrapper around it). The active guide row is matched on the
whole channel entry (all fields except the reducer-rewritten epgParams)
before widening to url and id, so copies that differ only in playback
headers or logo are told apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 17:16:23 +02:00
1 parent a49e56f5de
commit c00ebe69a2
7 files changed
+189 -65

No files matched your search

@@ -422,15 +422,18 @@ describeWithSqlite('guideWindowCondition rendered against SQLite', () => {
});
describe('EpgGuideQueryService', () => {
const getChannelMetadata = jest.fn();
const resolveChannelMetadata = jest.fn();
let service: EpgGuideQueryService;
beforeEach(() => {
getDatabase.mockReset();
getChannelMetadata.mockReset();
resolveChannelMetadata.mockReset();
(epgLogger.log as jest.Mock).mockReset();
(epgLogger.error as jest.Mock).mockReset();
service = new EpgGuideQueryService({ getChannelMetadata }, '[Test]');
service = new EpgGuideQueryService(
{ resolveChannelMetadata },
'[Test]'
);
});
it('returns an empty object (no per-key placeholders) for an invalid window', async () => {
@@ -440,11 +443,30 @@ describe('EpgGuideQueryService', () => {
toMs: FROM,
});
expect(result).toEqual({});
expect(getChannelMetadata).not.toHaveBeenCalled();
expect(resolveChannelMetadata).not.toHaveBeenCalled();
});
it('rejects coverage when the channel lookup itself fails', async () => {
resolveChannelMetadata.mockRejectedValue(new Error('lookup down'));
await expect(
service.getProgramCoverage({
channelIds: ['a'],
fromMs: FROM,
toMs: TO,
})
).rejects.toThrow('lookup down');
// Programmes keep the documented fail-soft answer.
await expect(
service.getProgramsForChannels({
channelIds: ['a'],
fromMs: FROM,
toMs: TO,
})
).resolves.toEqual({ a: [] });
});
it('rethrows a coverage failure after logging it', async () => {
getChannelMetadata.mockResolvedValue({
resolveChannelMetadata.mockResolvedValue({
a: { id: 'a', displayName: 'A', iconUrl: null },
});
getDatabase.mockRejectedValue(new Error('locked'));
@@ -458,7 +480,7 @@ describe('EpgGuideQueryService', () => {
});
it('resolves keys through channel metadata and maps rows back onto every requested key', async () => {
getChannelMetadata.mockResolvedValue({
resolveChannelMetadata.mockResolvedValue({
'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null },
'zdf.de': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null },
unknown: null,
@@ -490,7 +512,7 @@ describe('EpgGuideQueryService', () => {
toMs: TO,
});
expect(getChannelMetadata).toHaveBeenCalledWith(
expect(resolveChannelMetadata).toHaveBeenCalledWith(
['ZDF HD', 'zdf.de', 'unknown'],
{}
);
@@ -511,7 +533,7 @@ describe('EpgGuideQueryService', () => {
});
it('scopes the programme rows to the requested source URLs plus legacy (unsourced) rows', async () => {
getChannelMetadata.mockResolvedValue({
resolveChannelMetadata.mockResolvedValue({
a: { id: 'a', displayName: 'A', iconUrl: null },
});
const whereCalls: unknown[] = [];
@@ -526,7 +548,7 @@ describe('EpgGuideQueryService', () => {
sourceUrls: ['https://guide.example.com/epg.xml'],
});
expect(getChannelMetadata).toHaveBeenCalledWith(['a'], {
expect(resolveChannelMetadata).toHaveBeenCalledWith(['a'], {
sourceUrls: ['https://guide.example.com/epg.xml'],
});
const condition = flattenSql(whereCalls[0]).toLowerCase();
@@ -535,7 +557,7 @@ describe('EpgGuideQueryService', () => {
});
it('fails soft when the database throws', async () => {
getChannelMetadata.mockResolvedValue({
resolveChannelMetadata.mockResolvedValue({
a: { id: 'a', displayName: 'A', iconUrl: null },
});
getDatabase.mockRejectedValue(new Error('locked'));
@@ -550,7 +572,7 @@ describe('EpgGuideQueryService', () => {
});
it('keeps a provider key named __proto__ as an own response property', async () => {
getChannelMetadata.mockResolvedValue({
resolveChannelMetadata.mockResolvedValue({
__proto__: { id: 'proto.tv', displayName: 'Proto', iconUrl: null },
});
getDatabase.mockResolvedValue({
@@ -581,7 +603,7 @@ describe('EpgGuideQueryService', () => {
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({});
resolveChannelMetadata.mockResolvedValue({});
const result = await service.getProgramsForChannels({
channelIds: ids,
@@ -602,7 +624,7 @@ describe('EpgGuideQueryService', () => {
});
it('reports coverage for the requested keys whose channel has a programme in the window', async () => {
getChannelMetadata.mockResolvedValue({
resolveChannelMetadata.mockResolvedValue({
'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null },
ARTE: { id: 'arte.de', displayName: 'ARTE', iconUrl: null },
none: null,
@@ -627,7 +649,7 @@ describe('EpgGuideQueryService', () => {
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({});
resolveChannelMetadata.mockResolvedValue({});
await service.getProgramCoverage({
channelIds: ids,
@@ -635,7 +657,7 @@ describe('EpgGuideQueryService', () => {
toMs: TO,
});
expect(getChannelMetadata).toHaveBeenCalledWith(ids, {});
expect(resolveChannelMetadata).toHaveBeenCalledWith(ids, {});
});
it('logs the coverage-specific truncation message when the coverage cap drops keys', async () => {
@@ -643,7 +665,7 @@ describe('EpgGuideQueryService', () => {
{ length: EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST + 1 },
(_, index) => `ch-${index}`
);
getChannelMetadata.mockResolvedValue({});
resolveChannelMetadata.mockResolvedValue({});
await service.getProgramCoverage({
channelIds: ids,
@@ -38,7 +38,8 @@ export type {
NormalizedGuideWindow,
} from './epg-guide-window.util';
type ChannelResolver = Pick<EpgQueryService, 'getChannelMetadata'>;
/** The strict resolver: a lookup failure rejects instead of answering `{}`. */
type ChannelResolver = Pick<EpgQueryService, 'resolveChannelMetadata'>;
/**
* Overlap test in SQLite `datetime()` so provider-local offsets in the
@@ -252,7 +253,7 @@ export class EpgGuideQueryService {
private async resolveChannelIds(
window: NormalizedGuideWindow
): Promise<Map<string, string>> {
const metadata = await this.resolver.getChannelMetadata(
const metadata = await this.resolver.resolveChannelMetadata(
window.channelIds,
window.sourceUrls.length > 0
? { sourceUrls: window.sourceUrls }
@@ -344,52 +344,62 @@ export class EpgQueryService {
}
}
/**
* Resolve channel lookup keys to XMLTV channel metadata. Throws on a
* database failure so callers that must tell "no match" apart from "could
* not look up" (the guide's coverage read) can fail open.
*/
async resolveChannelMetadata(
channelIds: string[],
options: { sourceUrls?: string[] } = {}
): Promise<Record<string, EpgChannelMetadata | null>> {
const normalizedChannelIds =
this.normalizeChannelLookupKeys(channelIds);
if (normalizedChannelIds.length === 0) {
return {};
}
const db = await getDatabase();
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
let candidates = await this.selectChannelMetadataCandidates(
db,
normalizedChannelIds,
sourceUrls
);
if (sourceUrls.length > 0) {
const missingChannelIds = normalizedChannelIds.filter(
(channelId) =>
!this.resolveChannelMetadataCandidate(channelId, candidates)
);
if (missingChannelIds.length > 0) {
candidates = [
...candidates,
...(await this.selectChannelMetadataCandidates(
db,
missingChannelIds,
sourceUrls,
{ legacyOnly: true }
)),
];
}
}
return Object.fromEntries(
normalizedChannelIds.map((channelId) => [
channelId,
this.resolveChannelMetadataCandidate(channelId, candidates),
])
);
}
/** Fail-soft variant of `resolveChannelMetadata`: a failure logs and answers `{}`. */
async getChannelMetadata(
channelIds: string[],
options: { sourceUrls?: string[] } = {}
): Promise<Record<string, EpgChannelMetadata | null>> {
try {
const normalizedChannelIds =
this.normalizeChannelLookupKeys(channelIds);
if (normalizedChannelIds.length === 0) {
return {};
}
const db = await getDatabase();
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
let candidates = await this.selectChannelMetadataCandidates(
db,
normalizedChannelIds,
sourceUrls
);
if (sourceUrls.length > 0) {
const missingChannelIds = normalizedChannelIds.filter(
(channelId) =>
!this.resolveChannelMetadataCandidate(
channelId,
candidates
)
);
if (missingChannelIds.length > 0) {
candidates = [
...candidates,
...(await this.selectChannelMetadataCandidates(
db,
missingChannelIds,
sourceUrls,
{ legacyOnly: true }
)),
];
}
}
return Object.fromEntries(
normalizedChannelIds.map((channelId) => [
channelId,
this.resolveChannelMetadataCandidate(channelId, candidates),
])
);
return await this.resolveChannelMetadata(channelIds, options);
} catch (error) {
epgLogger.error(
this.loggerLabel,
@@ -0,0 +1,50 @@
import { Channel } from '@iptvnator/shared/interfaces';
import { isSameChannelEntry } from './channel-entry-identity.util';
function entry(overrides: Partial<Channel> = {}): Channel {
return {
id: 'dup',
url: 'https://example.com/dup.m3u8',
name: 'Dup',
group: { title: 'News' },
tvg: { id: '', name: '', url: '', logo: '', rec: '' },
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
...overrides,
} as Channel;
}
describe('isSameChannelEntry', () => {
it('treats spread copies with a rewritten epgParams as the same entry', () => {
const a = entry();
expect(isSameChannelEntry(a, { ...a, epgParams: 'x' })).toBe(true);
});
it('ignores key order', () => {
const a = entry();
const reordered = JSON.parse(JSON.stringify(a)) as Channel;
expect(isSameChannelEntry(a, reordered)).toBe(true);
});
it('tells entries apart by playback headers, logo or group', () => {
const a = entry();
expect(
isSameChannelEntry(
a,
entry({
http: { referrer: '', 'user-agent': 'VLC', origin: '' },
})
)
).toBe(false);
expect(
isSameChannelEntry(
a,
entry({ tvg: { ...a.tvg, logo: 'https://x/logo.png' } })
)
).toBe(false);
expect(
isSameChannelEntry(a, entry({ group: { title: 'Sports' } }))
).toBe(false);
});
});
@@ -0,0 +1,32 @@
import { Channel } from '@iptvnator/shared/interfaces';
function canonical(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(canonical).join(',')}]`;
}
if (value && typeof value === 'object') {
const entries = Object.keys(value as Record<string, unknown>)
.sort()
.map(
(key) =>
`${JSON.stringify(key)}:${canonical(
(value as Record<string, unknown>)[key]
)}`
);
return `{${entries.join(',')}}`;
}
return JSON.stringify(value) ?? 'undefined';
}
/**
* Whether two playlist entries are the same entry, field for field. The
* store spreads the selected channel and resets `epgParams`, so identity is
* lost and only the data can tell copies apart: two rows may share id, url,
* group and name yet differ in playback headers or logo, and each is its own
* guide row. `epgParams` is excluded because the reducer rewrites it.
*/
export function isSameChannelEntry(a: Channel, b: Channel): boolean {
const { epgParams: _a, ...restA } = a;
const { epgParams: _b, ...restB } = b;
return canonical(restA) === canonical(restB);
}
@@ -190,6 +190,19 @@ describe('M3uEpgGuideSourceService', () => {
expect(service.activeChannelId()).toBe('0:dup');
});
it('marks the copy whose playback headers match when everything else is equal', () => {
const plain = makeChannel('dup');
const withUa = {
...makeChannel('dup'),
http: { referrer: '', 'user-agent': 'VLC/3', origin: '' },
};
channels.set([plain, withUa]);
activeChannel.set({ ...withUa, epgParams: '' });
expect(service.activeChannelId()).toBe('1:dup');
activeChannel.set({ ...plain, epgParams: '' });
expect(service.activeChannelId()).toBe('0:dup');
});
it('propagates a coverage failure so the guide keeps coverage unknown', async () => {
getProgramCoverage.mockRejectedValue(new Error('bridge down'));
await expect(
@@ -17,6 +17,7 @@ import {
EpgGuideWindow,
} from '@iptvnator/ui/epg';
import { createM3uChannelPlaybackRequest } from '../video-player/m3u-channel-playback-actions';
import { isSameChannelEntry } from './channel-entry-identity.util';
export interface M3uEpgGuideInputs {
/** Guide-eligible channels (host excludes radio and recognised movies). */
@@ -173,12 +174,7 @@ export class M3uEpgGuideSourceService implements EpgGuideSource {
(row) => row.channel.id === active.id
);
const match =
sameId.find(
(row) =>
row.channel.url === active.url &&
row.channel.group?.title === active.group?.title &&
row.channel.name === active.name
) ??
sameId.find((row) => isSameChannelEntry(row.channel, active)) ??
sameId.find((row) => row.channel.url === active.url) ??
sameId[0];
return match?.rowId ?? null;