From 9071bbf19b74cf90ab94b0f6bba19a06dbbf66c3 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 17:50:55 +0200 Subject: [PATCH] fix(epg): name search hits, keep guide coverage strict on mapping failures - Search results and the unresolved programme dialog show the channel's display name (playlist row name, else the XMLTV display name the search joined in) instead of the raw XMLTV id. - The guide coverage read resolves manual mappings through a strict variant that rejects on database failure, so a mapped channel can never be reported as uncovered and hidden by "Only with EPG". - Architecture doc describes the tiered active-row resolution. Co-Authored-By: Claude Fable 5.1 --- .../src/app/events/epg-guide.events.spec.ts | 31 ++++++++++++++++--- .../src/app/events/epg-mapping.service.ts | 16 ++++++++-- .../src/app/events/epg.events.ts | 5 ++- docs/architecture/m3u-playlist-module.md | 11 +++++-- .../m3u-epg-guide-source.service.spec.ts | 7 +++++ .../epg-guide/m3u-epg-guide-source.service.ts | 18 +++++++---- .../epg-guide/epg-guide-dialog.controller.ts | 5 ++- .../epg/src/lib/epg-guide/epg-guide-source.ts | 2 ++ .../lib/epg-guide/epg-guide.component.html | 2 +- 9 files changed, 77 insertions(+), 20 deletions(-) diff --git a/apps/electron-backend/src/app/events/epg-guide.events.spec.ts b/apps/electron-backend/src/app/events/epg-guide.events.spec.ts index 75676c784..ccf491003 100644 --- a/apps/electron-backend/src/app/events/epg-guide.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg-guide.events.spec.ts @@ -3,6 +3,7 @@ import type EpgEventsType from './epg.events'; const getProgramsForChannels = jest.fn(); const getProgramCoverage = jest.fn(); const resolveChannelIds = jest.fn(); +const resolveChannelIdsStrict = jest.fn(); const ipcHandlers = new Map< string, (event: unknown, args: unknown) => Promise @@ -25,13 +26,14 @@ jest.mock('./epg-guide-query.service', () => ({ epgGuideQueryService: { getProgramsForChannels: (...args: unknown[]) => getProgramsForChannels(...args), - getProgramCoverage: (...args: unknown[]) => - getProgramCoverage(...args), + getProgramCoverage: (...args: unknown[]) => getProgramCoverage(...args), }, })); jest.mock('./epg-mapping.service', () => ({ resolveChannelIds: (...args: unknown[]) => resolveChannelIds(...args), + resolveChannelIdsStrict: (...args: unknown[]) => + resolveChannelIdsStrict(...args), queryByResolvedChannelIds: jest.fn(), handleGetEpgMapping: jest.fn(), handleGetEpgMappingsBatch: jest.fn(), @@ -71,6 +73,7 @@ describe('EPG guide IPC handlers', () => { (mock) => mock.mockReset() ); resolveChannelIds.mockResolvedValue(new Map()); + resolveChannelIdsStrict.mockResolvedValue(new Map()); ({ default: EpgEvents } = await import('./epg.events')); @@ -97,9 +100,7 @@ describe('EPG guide IPC handlers', () => { } it('applies a manual mapping before querying and keys the answer back by the requested key', async () => { - resolveChannelIds.mockResolvedValue( - new Map([['m3u-key', 'xmltv.id']]) - ); + resolveChannelIds.mockResolvedValue(new Map([['m3u-key', 'xmltv.id']])); getProgramsForChannels.mockResolvedValue({ 'xmltv.id': [program] }); const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', { @@ -161,6 +162,26 @@ describe('EPG guide IPC handlers', () => { expect(result).toEqual({}); }); + it('rejects coverage when the manual-mapping lookup fails, but still answers programmes', async () => { + resolveChannelIdsStrict.mockRejectedValue(new Error('mapping db down')); + getProgramsForChannels.mockResolvedValue({ a: [] }); + + await expect( + invoke('EPG_GET_PROGRAM_COVERAGE', { + channelIds: ['a'], + fromMs: 0, + toMs: 1, + }) + ).rejects.toThrow('mapping db down'); + await expect( + invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', { + channelIds: ['a'], + fromMs: 0, + toMs: 1, + }) + ).resolves.toEqual({ a: [] }); + }); + it('de-duplicates requested coverage keys and keeps only the covered ones', async () => { getProgramCoverage.mockResolvedValue(['a']); diff --git a/apps/electron-backend/src/app/events/epg-mapping.service.ts b/apps/electron-backend/src/app/events/epg-mapping.service.ts index 7b767ce0a..2778507e9 100644 --- a/apps/electron-backend/src/app/events/epg-mapping.service.ts +++ b/apps/electron-backend/src/app/events/epg-mapping.service.ts @@ -34,14 +34,24 @@ export async function resolveChannelIds( channelIds: string[] ): Promise> { try { - const db = await getDatabase(); - const mappings = await getEpgMappingsBatch(db, channelIds); - return mappings; + return await resolveChannelIdsStrict(channelIds); } catch { return new Map(); } } +/** + * Same lookup, but a database failure rejects. The guide's coverage read + * uses it: answering "unmapped" for a mapped channel would let the + * "Only with EPG" filter hide it, whereas a rejection keeps coverage unknown. + */ +export async function resolveChannelIdsStrict( + channelIds: string[] +): Promise> { + const db = await getDatabase(); + return getEpgMappingsBatch(db, channelIds); +} + /** * Run a batch query against mapping-resolved channel IDs and remap the results * back onto the original request keys the renderer asked for. diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 2bac72f18..91801b10b 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -20,6 +20,7 @@ import { handleSetEpgMapping, queryByResolvedChannelIds, resolveChannelIds, + resolveChannelIdsStrict, } from './epg-mapping.service'; /** @@ -312,7 +313,9 @@ export default class EpgEvents { const requested = Array.isArray(args?.channelIds) ? args.channelIds : []; - const mapping = await resolveChannelIds(requested); + // Strict on purpose: a mapping lookup failure must reject (coverage + // unknown), not report mapped channels as unmapped and uncovered. + const mapping = await resolveChannelIdsStrict(requested); const resolvedIds = requested.map((id) => mapping.get(id) ?? id); const covered = new Set( await epgGuideQueryService.getProgramCoverage({ diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index b54e891eb..7e1a5f78d 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1147,9 +1147,14 @@ without that both copies lit up as playing and activating either one played the first, and suffixing only the repeats was not enough because a real channel id can itself look like a generated suffix (ids `x`, `x`, `x#1` produced `x#1` twice). Ids move with the scope and the channel list, so only -ids the guide was just handed may be passed back. The active channel marks the -FIRST row carrying its id (`null` when it is outside the scope), and -`activate(rowId)` resolves the row back to its own channel. Opening the +ids the guide was just handed may be passed back. The active channel is +resolved to a row in three steps, because the store spreads the selected +channel (identity is gone) and copies can share an id: the row whose channel +matches the playing one field for field (every field except the +reducer-rewritten `epgParams`, key order ignored — `isSameChannelEntry`), +else the first same-id row with the same stream url, else the first same-id +row; `null` when the channel is outside the scope. `activate(rowId)` +resolves the row back to its own channel object. Opening the guide mirrors the sidebar view (`applyInitialScope`): favorites stays favorites, and the groups view opens on the group the sidebar's rail is SHOWING — forwarded from `GroupsViewComponent.selectedGroupChange` through the 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 23dccdacd..2f5fd29de 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 @@ -339,7 +339,14 @@ describe('M3uEpgGuideSourceService', () => { searchPrograms.mockResolvedValue([program('a.tv'), program('x')]); const hits = await service.searchPrograms('news'); expect(hits.map((hit) => hit.channelId)).toEqual(['0:a', null]); + expect(hits.map((hit) => hit.channelName)).toEqual(['Channel a', null]); expect(hits[1].program.title).toBe('x show'); + searchPrograms.mockResolvedValue([ + { ...program('x'), channelName: 'XMLTV Name' }, + ]); + expect((await service.searchPrograms('x'))[0].channelName).toBe( + 'XMLTV Name' + ); expect(searchPrograms).toHaveBeenCalledWith('news', 20); }); }); 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 a2cc9f97e..eeab453c2 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 @@ -292,18 +292,24 @@ export class M3uEpgGuideSourceService implements EpgGuideSource { async searchPrograms(query: string): Promise { const programs = (await this.epgBridge.searchPrograms(query, SEARCH_LIMIT)) ?? []; - const byKey = new Map(); + const byKey = new Map(); for (const channel of this.channels()) { // Duplicate rows share an EPG key; jump to the first, like // `activeChannelId`. if (channel.epgKey !== null && !byKey.has(channel.epgKey)) { - byKey.set(channel.epgKey, channel.id); + byKey.set(channel.epgKey, channel); } } - return programs.map((program) => ({ - channelId: byKey.get(program.channel) ?? null, - program, - })); + return programs.map((program) => { + const row = byKey.get(program.channel); + return { + channelId: row?.id ?? null, + // The playlist's own name when the row is known, else the + // XMLTV display name the search joined in; never the raw id. + channelName: row?.name ?? program.channelName ?? null, + program, + }; + }); } private keyedChannels( diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-dialog.controller.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-dialog.controller.ts index 2ad91bec6..c7515bd59 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-dialog.controller.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-dialog.controller.ts @@ -88,7 +88,10 @@ export class EpgGuideDialogController { channelName: channel.name, channelLogo: channel.logoUrl, } - : { ...hit.program } + : { + ...hit.program, + channelName: hit.channelName ?? null, + } ) .subscribe(); } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts index a6387f1eb..a402af8f3 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts @@ -48,6 +48,8 @@ export interface EpgGuideCatchUp { */ export interface EpgGuideSearchHit { channelId: string | null; + /** Display name for the hit's channel: the playlist row's when resolved, else the guide source's own (e.g. the XMLTV display name). */ + channelName?: string | null; program: EpgProgram; } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.html b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.html index b4a2df337..e23f0c480 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.html +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.html @@ -47,7 +47,7 @@ searchHitStartMs(hit) | date: 'EEE d MMM HH:mm' : '' : currentLocale() }} - · {{ hit.program.channel }} + · {{ hit.channelName ?? hit.program.channel }} } @empty {