From c00ebe69a2c986dac8b52a9b4c0a2443a7fbcd70 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 17:16:23 +0200 Subject: [PATCH] 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 --- .../events/epg-guide-query.service.spec.ts | 54 +++++++---- .../src/app/events/epg-guide-query.service.ts | 5 +- .../src/app/events/epg-query.service.ts | 92 ++++++++++--------- .../channel-entry-identity.util.spec.ts | 50 ++++++++++ .../epg-guide/channel-entry-identity.util.ts | 32 +++++++ .../m3u-epg-guide-source.service.spec.ts | 13 +++ .../epg-guide/m3u-epg-guide-source.service.ts | 8 +- 7 files changed, 189 insertions(+), 65 deletions(-) create mode 100644 libs/playlist/m3u/feature-player/src/lib/epg-guide/channel-entry-identity.util.spec.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/epg-guide/channel-entry-identity.util.ts diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts index 5cbe297e4..8e62f33a7 100644 --- a/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts @@ -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, diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.ts index 36badde69..67cefbd42 100644 --- a/apps/electron-backend/src/app/events/epg-guide-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.ts @@ -38,7 +38,8 @@ export type { NormalizedGuideWindow, } from './epg-guide-window.util'; -type ChannelResolver = Pick; +/** The strict resolver: a lookup failure rejects instead of answering `{}`. */ +type ChannelResolver = Pick; /** * Overlap test in SQLite `datetime()` so provider-local offsets in the @@ -252,7 +253,7 @@ export class EpgGuideQueryService { private async resolveChannelIds( window: NormalizedGuideWindow ): Promise> { - const metadata = await this.resolver.getChannelMetadata( + const metadata = await this.resolver.resolveChannelMetadata( window.channelIds, window.sourceUrls.length > 0 ? { sourceUrls: window.sourceUrls } diff --git a/apps/electron-backend/src/app/events/epg-query.service.ts b/apps/electron-backend/src/app/events/epg-query.service.ts index c91bc567e..22544a29b 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -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> { + 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> { 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, diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/channel-entry-identity.util.spec.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/channel-entry-identity.util.spec.ts new file mode 100644 index 000000000..791123752 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/channel-entry-identity.util.spec.ts @@ -0,0 +1,50 @@ +import { Channel } from '@iptvnator/shared/interfaces'; +import { isSameChannelEntry } from './channel-entry-identity.util'; + +function entry(overrides: Partial = {}): 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); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/channel-entry-identity.util.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/channel-entry-identity.util.ts new file mode 100644 index 000000000..fdcf7829d --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/channel-entry-identity.util.ts @@ -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) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${canonical( + (value as Record)[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); +} diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts index 86b7a6c13..419ad6798 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts @@ -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( diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts index abb5b490a..71b541888 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts @@ -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;