mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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
@@ -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,
|
||||
|
||||
+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