mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
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:
1 parent
a49e56f5de
commit
c00ebe69a2
7 files changed
+189
-65
No files matched your search
+50
@@ -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);
|
||||
}
|
||||
+13
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user