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

@@ -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;